Memperbarui Objek dalam State

State bisa menampung berbagai jenis nilai JavaScript, termasuk objek. Tetapi objek-objek yang disimpan dalam React sebaiknya tidak diperbarui secara langsung. Sebaiknya, ketika sebuah objek ingin diperbarui, Anda perlu membuat objek baru atau menyalin objek yang ingin diubah, kemudian mengubah state untuk menggunakan objek hasil salinan tersebut.

Anda akan mempelajari

  • Bagaimana cara memperbarui objek di dalam React state
  • Bagaimana cara memperbarui objek yang bersarang tanpa melakukan mutasi
  • Apa itu immutability, dan bagaimana agar tidak merusaknya
  • Bagaimana cara mempersingkat penyalinan objek dengan Immer

Apa itu mutasi?

Anda bisa menyimpan segala jenis nilai JavaScript di dalam state.

const [x, setX] = useState(0);

Sejauh ini Anda sudah bisa menggunakan angka, string, dan boolean. Nilai-nilai JavaScript tersebut bersifat “immutable”, yang berarti tidak bisa diubah atau “read-only”. Anda bisa memicu render ulang untuk menimpa sebuah nilai:

setX(5);

Nilai state x berubah dari 0 menjadi 5, tetapi angka 0 itu sendiri tidak berubah. Melakukan perubahan terhadap nilai-nilai primitif yang bawaan seperti angka, string, dan boolean itu mustahil di JavaScript.

Sekarang pikirkan sebuah objek dalam state:

const [position, setPosition] = useState({ x: 0, y: 0 });

Secara teknis, Anda bisa memperbarui isi konten objek itu sendiri. Hal ini disebut sebagai mutasi:

position.x = 5;

Tetapi, meskipun objek di React secara teknis bisa diubah, Anda harus memperlakukan mereka seolah-olah objek itu immutable—seperti angka, boolean, dan string. Selain melakukan mutasi, Anda seharusnya menimpa mereka.

Perlakukan state seperti read-only

Dengan kata lain, Anda seharusnya memperlakukan semua objek JavaScript yang ditaruh di dalam state seperti read-only.

Berikut adalah contoh kode yang menampung objek di dalam state untuk merepresentasikan posisi kursor saat ini. Titik merah tersebut seharusnya bergerak ketika Anda menyentuh layar atau kursor digerakkan pada daerah preview. Tetapi titik merah tersebut diam di tempat saja pada posisi awal:

import { useState } from 'react';
export default function MovingDot() {
  const [position, setPosition] = useState({
    x: 0,
    y: 0
  });
  return (
    <div
      onPointerMove={e => {
        position.x = e.clientX;
        position.y = e.clientY;
      }}
      style={{
        position: 'relative',
        width: '100vw',
        height: '100vh',
      }}>
      <div style={{
        position: 'absolute',
        backgroundColor: 'red',
        borderRadius: '50%',
        transform: `translate(${position.x}px, ${position.y}px)`,
        left: -10,
        top: -10,
        width: 20,
        height: 20,
      }} />
    </div>
  );
}

Masalahnya terdapat pada potongan kode berikut.

onPointerMove={e => {
position.x = e.clientX;
position.y = e.clientY;
}}

Kode tersebut melakukan modifikasi objek yang ditempatkan ke position dari render sebelumnya. Tetapi tanpa menggunakan fungsi yang mengubah state, React tidak tahu bahwa objek tersebut telah berubah. Sehingga React tidak melakukan apa-apa. Hal ini sama seperti mencoba mengubah pesanan makanan setelah makanannya dihabiskan. Meskipun mengubah state bisa bekerja pada kasus tertentu, kami tidak menyarankannya. Anda harus memperlakukan nilai state yang aksesnya Anda miliki ketika render sebagai read-only.

Untuk memicu render ulang pada kasus ini, buatlah objek baru dan berikan objek tersebut kepada fungsi yang mengubah state:

onPointerMove={e => {
setPosition({
x: e.clientX,
y: e.clientY
});
}}

Dengan setPosition, Anda memberi tahu React:

  • Timpa position dengan objek baru yang diberikan
  • Lalu render komponen tersebut lagi

Perhatikan bahwa sekarang titik merah sudah mengikuti kursor Anda ketika Anda menyentuh layar atau menggerakkan kursor pada daerah preview:

import { useState } from 'react';
export default function MovingDot() {
  const [position, setPosition] = useState({
    x: 0,
    y: 0
  });
  return (
    <div
      onPointerMove={e => {
        setPosition({
          x: e.clientX,
          y: e.clientY
        });
      }}
      style={{
        position: 'relative',
        width: '100vw',
        height: '100vh',
      }}>
      <div style={{
        position: 'absolute',
        backgroundColor: 'red',
        borderRadius: '50%',
        transform: `translate(${position.x}px, ${position.y}px)`,
        left: -10,
        top: -10,
        width: 20,
        height: 20,
      }} />
    </div>
  );
}

Pendalaman

Mutasi Lokal itu baik-baik saja

Kode seperti ini merupakan masalah karena ia memodifikasi objek yang sudah ada pada state:

position.x = e.clientX;
position.y = e.clientY;

Tetapi kode seperti ini itu baik-baik saja karena Anda melakukan mutasi terhadap objek yang baru saja Anda buat:

const nextPosition = {};
nextPosition.x = e.clientX;
nextPosition.y = e.clientY;
setPosition(nextPosition);

Sebenarnya, penulisan kode di atas sama dengan kode berikut:

setPosition({
x: e.clientX,
y: e.clientY
});

Mutasi hanya menjadi masalah ketika Anda mengubah objek-objek yang sudah ada yang berada di dalam state. Mengubah objek yang baru saja Anda buat itu baik-baik saja karena belum ada kode lain yang menggunakannya. Mengubah objek tersebut tidak akan mempengaruhi sesuatu yang bergantung pada objek tersebut secara tidak sengaja. Hal ini disebut sebagai “mutasi lokal”. Anda bahkan bisa melakukan mutasi lokal ketika melakukan render. Sangat mudah dan baik-baik saja!

Menyalin objek-objek dengan sintaksis spread

Pada contoh sebelumnya, objek position selalu dibentuk ulang dari posisi kursor saat ini. Tetapi, pada umumnya, Anda ingin menyimpan data sebelumnya sebagai bagian dari objek baru yang sedang dibuat. Sebagai contoh, Anda ingin mengubah hanya satu data bidang pada sebuah formulir tanpa mengubah data-data sebelumnya pada bidang lainnya.

Bidang isian berikut tidak bekerja karena handler onChange mengubah state:

import { useState } from 'react';

export default function Form() {
  const [person, setPerson] = useState({
    firstName: 'Barbara',
    lastName: 'Hepworth',
    email: 'bhepworth@sculpture.com'
  });

  function handleFirstNameChange(e) {
    person.firstName = e.target.value;
  }

  function handleLastNameChange(e) {
    person.lastName = e.target.value;
  }

  function handleEmailChange(e) {
    person.email = e.target.value;
  }

  return (
    <>
      <label>
        Nama depan:
        <input
          value={person.firstName}
          onChange={handleFirstNameChange}
        />
      </label>
      <label>
        Nama belakang:
        <input
          value={person.lastName}
          onChange={handleLastNameChange}
        />
      </label>
      <label>
        Email:
        <input
          value={person.email}
          onChange={handleEmailChange}
        />
      </label>
      <p>
        {person.firstName}{' '}
        {person.lastName}{' '}
        ({person.email})
      </p>
    </>
  );
}

Sebagai contoh, baris ini mengubah state dari render sebelumnya:

person.firstName = e.target.value;

Cara yang dapat diandalkan untuk mendapatkan perilaku yang diinginkan adalah dengan membuat objek baru dan mengopernya ke setPerson. Tetapi di sini, Anda juga ingin menyalin data sebelumnya ke objek tersebut karena hanya satu bidang saja yang berubah:

setPerson({
firstName: e.target.value, // Nama depan baru dari bidang isian
lastName: person.lastName,
email: person.email
});

Anda bisa menggunakan sintaksis ... spread objek agar Anda tidak perlu menyalin semua properti secara terpisah.

setPerson({
...person, // Salin properti-properti lama
firstName: e.target.value // Tetapi perbarui properti yang ini
});

Sekarang formulirnya berfungsi!

Perhatikan bagaimana Anda tidak mendeklarasikan variabel state yang terpisah untuk setiap bidang isian. Untuk formulir yang besar, menyimpan semua data dalam sebuah objek sebagai satu kelompok merupakan hal yang mudah—selama objek tersebut diperbarui dengan benar!

import { useState } from 'react';

export default function Form() {
  const [person, setPerson] = useState({
    firstName: 'Barbara',
    lastName: 'Hepworth',
    email: 'bhepworth@sculpture.com'
  });

  function handleFirstNameChange(e) {
    setPerson({
      ...person,
      firstName: e.target.value
    });
  }

  function handleLastNameChange(e) {
    setPerson({
      ...person,
      lastName: e.target.value
    });
  }

  function handleEmailChange(e) {
    setPerson({
      ...person,
      email: e.target.value
    });
  }

  return (
    <>
      <label>
        Nama depan:
        <input
          value={person.firstName}
          onChange={handleFirstNameChange}
        />
      </label>
      <label>
        Nama belakang:
        <input
          value={person.lastName}
          onChange={handleLastNameChange}
        />
      </label>
      <label>
        Email:
        <input
          value={person.email}
          onChange={handleEmailChange}
        />
      </label>
      <p>
        {person.firstName}{' '}
        {person.lastName}{' '}
        ({person.email})
      </p>
    </>
  );
}

Perhatikan bahwa ... sintaksis spread sebenarnya adalah “dangkal”—benda-benda yang disalin hanya sedalam satu level. Hal ini membuatnya cepat, tetapi itu juga berarti bahwa jika Anda ingin memperbarui properti yang bersarang, Anda harus menggunakannya lebih dari sekali.

Pendalaman

Menggunakan satu event handler untuk beberapa bidang

Anda juga bisa menggunakan tanda [ dan ] di dalam definisi objek untuk menentukan sebuah properti dengan nama yang dinamis. Berikut adalah contoh yang sama, tetapi dengan satu event handler daripada tiga yang berbeda:

import { useState } from 'react';

export default function Form() {
  const [person, setPerson] = useState({
    firstName: 'Barbara',
    lastName: 'Hepworth',
    email: 'bhepworth@sculpture.com'
  });

  function handleChange(e) {
    setPerson({
      ...person,
      [e.target.name]: e.target.value
    });
  }

  return (
    <>
      <label>
        Nama depan:
        <input
          name="firstName"
          value={person.firstName}
          onChange={handleChange}
        />
      </label>
      <label>
        Nama belakang:
        <input
          name="lastName"
          value={person.lastName}
          onChange={handleChange}
        />
      </label>
      <label>
        Email:
        <input
          name="email"
          value={person.email}
          onChange={handleChange}
        />
      </label>
      <p>
        {person.firstName}{' '}
        {person.lastName}{' '}
        ({person.email})
      </p>
    </>
  );
}

Di sini, e.target.name merujuk ke properti name yang diberikan ke elemen DOM <input>.

Memperbarui objek bersarang

Pikirkan sebuah objek bersarang dengan struktur sebagai berikut:

const [person, setPerson] = useState({
name: 'Niki de Saint Phalle',
artwork: {
title: 'Blue Nana',
city: 'Hamburg',
image: 'https://i.imgur.com/Sd1AgUOm.jpg',
}
});

Jika Anda ingin memperbarui person.artwork.city, jelas bagaimana melakukannya dengan mutasi:

person.artwork.city = 'New Delhi';

Tetapi dalam React, state itu diperlakukan sebagai immutable! Untuk mengubah city, pertama-tama Anda perlu membuat objek artwork yang baru (sudah terisi dengan data dari sebelumnya), kemudian buat objek person baru yang menunjuk ke artwork yang baru:

const nextArtwork = { ...person.artwork, city: 'New Delhi' };
const nextPerson = { ...person, artwork: nextArtwork };
setPerson(nextPerson);

Atau, ditulis sebagai satu panggilan fungsi:

setPerson({
...person, // Salin properti-properti lainnya
artwork: { // tetapi timpa artwork
...person.artwork, // dengan artwork yang sama
city: 'New Delhi' // tetapi di New Delhi!
}
});

Ini menjadi agak bertele-tele, tetapi berfungsi dengan baik dalam banyak kasus:

import { useState } from 'react';

export default function Form() {
  const [person, setPerson] = useState({
    name: 'Niki de Saint Phalle',
    artwork: {
      title: 'Blue Nana',
      city: 'Hamburg',
      image: 'https://i.imgur.com/Sd1AgUOm.jpg',
    }
  });

  function handleNameChange(e) {
    setPerson({
      ...person,
      name: e.target.value
    });
  }

  function handleTitleChange(e) {
    setPerson({
      ...person,
      artwork: {
        ...person.artwork,
        title: e.target.value
      }
    });
  }

  function handleCityChange(e) {
    setPerson({
      ...person,
      artwork: {
        ...person.artwork,
        city: e.target.value
      }
    });
  }

  function handleImageChange(e) {
    setPerson({
      ...person,
      artwork: {
        ...person.artwork,
        image: e.target.value
      }
    });
  }

  return (
    <>
      <label>
        Nama:
        <input
          value={person.name}
          onChange={handleNameChange}
        />
      </label>
      <label>
        Judul:
        <input
          value={person.artwork.title}
          onChange={handleTitleChange}
        />
      </label>
      <label>
        Kota:
        <input
          value={person.artwork.city}
          onChange={handleCityChange}
        />
      </label>
      <label>
        Gambar:
        <input
          value={person.artwork.image}
          onChange={handleImageChange}
        />
      </label>
      <p>
        <i>{person.artwork.title}</i>
        {' by '}
        {person.name}
        <br />
        (terletak di {person.artwork.city})
      </p>
      <img
        src={person.artwork.image}
        alt={person.artwork.title}
      />
    </>
  );
}

Pendalaman

Objek-objek sebenarnya tidak bersarang

Sebuah objek seperti ini terlihat “bersarang” dalam kode:

let obj = {
name: 'Niki de Saint Phalle',
artwork: {
title: 'Blue Nana',
city: 'Hamburg',
image: 'https://i.imgur.com/Sd1AgUOm.jpg',
}
};

Tetapi, “bersarang” adalah cara yang tidak akurat untuk memikirkan perilaku objek-objek. Ketika kode dieksekusi, tidak ada yang namanya objek “bersarang”. Sebenarnya Anda sedang melihat dua objek yang berbeda:

let obj1 = {
title: 'Blue Nana',
city: 'Hamburg',
image: 'https://i.imgur.com/Sd1AgUOm.jpg',
};

let obj2 = {
name: 'Niki de Saint Phalle',
artwork: obj1
};

Objek obj1 tidak berada “di dalam” obj2. Sebagai contoh, obj3 bisa “menunjuk” ke obj1 juga:

let obj1 = {
title: 'Blue Nana',
city: 'Hamburg',
image: 'https://i.imgur.com/Sd1AgUOm.jpg',
};

let obj2 = {
name: 'Niki de Saint Phalle',
artwork: obj1
};

let obj3 = {
name: 'Copycat',
artwork: obj1
};

Jika Anda memperbarui obj3.artwork.city, hal itu akan mempengaruhi obj2.artwork.city dan obj1.city. Hal ini disebabkan karena obj3.artwork, obj2.artwork, dan obj1 merupakan objek yang sama. Hal ini sulit untuk dilihat ketika Anda memikirkan objek-objek sebagai “bersarang”. Alih-alih, mereka sebenarnya adalah objek-objek terpisah yang “menunjuk” satu sama lain melalui properti-properti.

Menulis logika pembaruan yang ringkas dengan Immer

Jika state memiliki sarang yang dalam, mungkin Anda ingin mempertimbangkan untuk meratakannya. Tetapi, jika Anda tidak ingin mengubah struktur state, Anda mungkin lebih suka jalan pintas daripada spreads bersarang. Immer adalah library terkenal yang memungkinkan Anda menulis kode dengan sintaksis yang mudah tetapi melakukan mutasi dan mengurus proses penyalinan untuk Anda. Dengan Immer, kode yang ditulis terlihat seperti “melanggar peraturan” dan melakukan mutasi objek:

updatePerson(draft => {
draft.artwork.city = 'Lagos';
});

Tetapi tidak seperti mutasi biasa, hal ini tidak menimpa state sebelumnya!

Pendalaman

Bagaimana cara kerja Immer?

draft yang disediakan oleh Immer merupakan jenis objek yang spesial, disebut sebagai Proxy, yang “mencatat” apa pun yang Anda lakukan terhadap objek tersebut. Inilah alasan mengapa Anda bisa melakukan mutasi terhadap objek tersebut sebebas Anda! Di balik layar, Immer mencari tahu bagian mana dari draft yang berubah, dan membuat objek baru yang berisi perubahan-perubahan yang terjadi.

Untuk mencoba Immer:

  1. Jalankan npm install use-immer untuk menambahkan Immer sebagai dependensi
  2. Kemudian timpa import { useState } from 'react' dengan import { useImmer } from 'use-immer'

Berikut adalah contoh sebelumnya yang telah dikonversi menggunakan Immer:

{
  "dependencies": {
    "immer": "1.7.3",
    "react": "latest",
    "react-dom": "latest",
    "react-scripts": "latest",
    "use-immer": "0.5.1"
  },
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test --env=jsdom",
    "eject": "react-scripts eject"
  },
  "devDependencies": {}
}

Perhatikan bagaimana semua kode event handler menjadi lebih ringkas. Anda bisa mencampur dan mencocokkan useState dan useImmer dalam satu komponen sesuka hati Anda. Immer adalah cara yang bagus untuk menjaga agar handler logika pembaruan tetap ringkas, terutama jika ada objek bersarang di dalam state, dan penyalinan objek menyebabkan kode menjadi repetitif.

Pendalaman

Ada beberapa saran:

  • Debugging: Jika Anda menggunakan console.log dan tidak melakukan mutasi state, log masa lalu Anda tidak akan tertimpa dengan perubahan state yang baru. Dengan demikian, Anda bisa melihat dengan jelas bagaimana state berubah dari satu render ke render lainnya.
  • Optimisasi: Strategi optimisasi React umumnya mengandalkan melewati pekerjaan jika props atau state sebelumnya itu sama dengan yang selanjutnya. Jika Anda tidak pernah melakukan mutasi state, pengecekan perubahan menjadi sangat cepat. Jika prevObj === obj, Anda bisa yakin bahwa tidak ada konten yang berubah.
  • Fitur-fitur Baru: Fitur React baru yang sedang kami bangun mengandalkan state diperlakukan seperti snapshot. Jika Anda melakukan mutasi terhadap state sebelumnya, hal itu bisa mencegah Anda dari menggunakan fitur-fitur baru.
  • Perubahan Kebutuhan: Beberapa fitur aplikasi, seperti implementasi Undo/Redo, menunjukkan sejarah perubahan, atau membiarkan pengguna mengatur ulang sebuah formulir ke nilai yang lebih awal, lebih mudah dilakukan jika tidak ada yang dimutasi. Alasannya adalah Anda bisa menyimpan salinan-salinan dari state sebelumnya di dalam memori, dan menggunakannya kembali jika diinginkan. Jika Anda memulai dengan pendekatan mutasi, fitur-fitur seperti ini bisa menjadi rumit untuk ditambahkan di kemudian hari.
  • Implementasi yang Lebih Simpel: Karena React tidak bergantung pada mutasi, React tidak perlu melakukan hal spesial terhadap objek-objek Anda. React tidak perlu membajak properti-properti objek, membungkus objek-objek menjadi Proxies, atau melakukan pekerjaan lainnya ketika inisialisasi seperti kebanyakan solusi “reaktif” lainnya. Ini juga menjadi alasan mengapa React membiarkan Anda menaruh objek di dalam state—tidak peduli sebesar apa pun—tanpa isu-isu tambahan dalam hal performa atau ketepatan.

Dalam praktik, Anda bisa sering kali “lolos” dengan melakukan mutasi state dalam React, tetapi kami sangat menyarankan untuk tidak melakukan hal tersebut agar Anda bisa menggunakan fitur-fitur baru React yang dikembangkan dengan pendekatan ini. Para kontributor di masa depan dan bahkan mungkin diri Anda di masa depan akan berterima kasih!

Rekap

  • Perlakukan semua state dalam React sebagai immutable.
  • Ketika Anda menyimpan objek-objek dalam state, mutasi terhadap objek tersebut tidak akan memicu render dan akan mengubah state dari render “snapshots” sebelumnya.
  • Daripada mutasi objek, buat versi baru dari objek tersebut, dan picu render ulang dengan menyimpan objek baru tersebut ke state.
  • Kamu bisa menggunakan {...obj, something: 'newValue'} sintaksis objek spread untuk membuat salinan dari objek-objek.
  • Sintaksis spread adalah dangkal: ia hanya menyalin sedalam satu level.
  • Untuk memperbarui objek bersarang, Anda perlu menyalin semuanya sampai ke tempat pembaruan.
  • Untuk mengurangi kode salinan objek yang repetitif, gunakan Immer.

Tantangan 1 dari 3:
Perbaiki pembaruan state yang salah

Formulir berikut memiliki beberapa kesalahan. Tekan tombol yang menambah skor beberapa kali. Perhatikan bahwa skor tidak bertambah. Kemudian ubah nama depan, dan perhatikan bahwa skor tiba-tiba “menyusul” dengan perubahan-perubahan Anda. Terakhir, ubah nama belakang, dan perhatikan skor menghilang sepenuhnya.

Tugas Anda adalah memperbaiki semua kesalahan tersebut. Saat Anda memperbaikinya, jelaskan alasan mengapa setiap kesalahan terjadi.

import { useState } from 'react';

export default function Scoreboard() {
  const [player, setPlayer] = useState({
    firstName: 'Ranjani',
    lastName: 'Shettar',
    score: 10,
  });

  function handlePlusClick() {
    player.score++;
  }

  function handleFirstNameChange(e) {
    setPlayer({
      ...player,
      firstName: e.target.value,
    });
  }

  function handleLastNameChange(e) {
    setPlayer({
      lastName: e.target.value
    });
  }

  return (
    <>
      <label>
        Skor: <b>{player.score}</b>
        {' '}
        <button onClick={handlePlusClick}>
          +1
        </button>
      </label>
      <label>
        Nama depan:
        <input
          value={player.firstName}
          onChange={handleFirstNameChange}
        />
      </label>
      <label>
        Nama belakang:
        <input
          value={player.lastName}
          onChange={handleLastNameChange}
        />
      </label>
    </>
  );
}