Buka hampir semua codebase React dan Anda akan melihat refleks yang sama. Seorang developer perlu melacak sebuah nilai, jadi mereka menggunakan useState. Butuh penghitung (counter)? useState. Nilai input sementara? useState. Boolean untuk membalikkan modal? useState. Tak lama kemudian, sebuah komponen tunggal menampung selusin hook terpisah, yang masing-masing mengelola potongan kecil data yang mungkin atau mungkin tidak perlu bertahan di berbagai render. Hasilnya adalah kode yang berisik, re-render ekstra, dan state yang berserakan seperti uang receh di seluruh komponen.

Kebiasaan ini bisa dimaklumi. useState adalah hook pertama yang dipelajari sebagian besar dari kita, dan itu berfungsi. Namun, "berfungsi" tidak sama dengan "tepat". Memperlakukan setiap potongan data sebagai state reaktif menciptakan masalah yang baru muncul setelah komponen berkembang.

Hanya Karena Berubah, Bukan Berarti Butuh State

Tidak semua variabel yang berubah seiring waktu harus masuk ke dalam useState. Beberapa nilai hanyalah hasil dari sesuatu yang lain yang sudah Anda miliki. Jika Anda menyimpan nama lengkap pengguna dalam state hanya karena Anda menggabungkan firstName dan lastName, Anda sekarang memiliki dua sumber kebenaran (sources of truth). Ketika firstName diperbarui karena parent melakukan re-render, state fullName Anda akan menjadi basi (stale) sampai Anda menjalankan effect lain untuk menyinkronkannya. Anda tidak butuh effect sinkronisasi. Anda butuh nilai turunan (derived value).

const fullName = `${firstName} ${lastName}`;

Hitunglah nilai tersebut saat render. Jika proses derivasinya mahal, gunakan memoize. Namun, jangan berikan hook useState sendiri kecuali pengguna dapat mengedit nama lengkap tersebut secara independen dari bagian-bagiannya.

Aturan yang sama berlaku untuk daftar yang difilter. Jika Anda menyimpan allItems dan filteredItems di dalam state, Anda telah menggandakan beban pemeliharaan. Lakukan filter saat render. Simpan array sumber dan teks filter di dalam state, lalu turunkan daftar yang terlihat. Ini menjamin bahwa daftar yang difilter tidak akan pernah tidak sinkron dengan sumbernya.

Beberapa Nilai Tidak Boleh Memicu Re-renders

useState ada khusus untuk memberi tahu React bahwa sesuatu telah berubah dan DOM mungkin perlu diperbarui. Jika sebuah nilai berubah tetapi tidak ada bagian dari UI yang peduli dengan perubahan tersebut, useRef adalah alat yang lebih baik.

Timer dan interval adalah contoh klasiknya. Menyimpan ID setInterval di dalam state menyebabkan re-render setiap kali Anda memulai atau menghentikan timer, meskipun pengguna tidak dapat melihat ID interval tersebut. Sebuah ref menyimpan nilai tersebut tanpa memberi tahu React. Logika yang sama berlaku untuk melacak props sebelumnya, mengukur node DOM sebelum proses paint, atau menyimpan callback terbaru untuk custom hook. Tanyakan pada diri sendiri: apakah nilai ini perlu muncul di layar? Jika jawabannya tidak, kemungkinan besar ia tidak butuh useState.

Node DOM itu sendiri juga sebaiknya berada di dalam ref. Meskipun Anda dapat menyimpan elemen DOM di dalam state, melakukannya akan memicu re-render setelah callback ref berjalan. Dalam kebanyakan kasus, Anda hanya membutuhkan node tersebut untuk metode imperatif atau pengukuran, bukan untuk merendernya secara berbeda.

Jebakan Boolean

Masalah UI yang terkait cenderung meluas ketika setiap flag memiliki hook sendiri. Anda melihat komponen dengan isLoading, isError, dan isSuccess yang didefinisikan sebagai tiga boolean terpisah. Masalahnya adalah ketiga state ini tidak independen. Jika isLoading dan isSuccess keduanya bernilai true, UI Anda berada dalam kondisi yang mustahil, namun TypeScript dan React akan tetap membiarkan Anda merendernya.

Mengelompokkan state yang terkait dapat mencegah kombinasi yang tidak valid ini. Alih-alih menggunakan tiga boolean, lacaklah satu string status: 'idle', 'loading', 'success', atau 'error'. Hanya satu yang bisa aktif pada satu waktu, yang mana akan menghilangkan state yang mustahil pada level tipe data. Jika datanya lebih kompleks, sebuah objek dengan discriminated union akan membuat segalanya lebih rapi. Saat Anda mendapati diri Anda memperbarui beberapa panggilan useState di dalam handler event yang sama, itu adalah sinyal bahwa nilai-nilai tersebut seharusnya disatukan.

Gunakan useReducer, Bukan useState Lainnya

Ada titik di mana pembaruan state menjadi seperti permainan whack-a-mole. Anda memanggil setA, lalu setB, lalu secara kondisional setC, semuanya di dalam satu fungsi. Developer berikutnya yang membaca kode tersebut harus menelusuri urutannya untuk memahami apa yang sebenarnya dilakukan komponen tersebut.

useReducer sangat unggul di sini. Ia tidak menggantikan useState karena ia lebih canggih; ia menggantikan useState karena logikanya menuntut demikian. Sebuah reducer memusatkan bagaimana state berubah. Alih-alih menyebarkan perintah imperatif di seluruh event handler, Anda mengirimkan sebuah intensi: dispatch({ type: 'submitted' }). Reducer memutuskan seperti apa state berikutnya. Ini membuat pengujian menjadi sangat mudah, karena logika state Anda adalah fungsi murni (pure function). Ini juga membuat debugging lebih mudah, karena setiap perubahan meninggalkan aksi yang dapat dilacak.

Anda tidak memerlukan Redux untuk membenarkan penggunaan reducer. Jika Anda memiliki tiga atau lebih variabel state yang diperbarui secara bersamaan, atau jika state berikutnya sangat bergantung pada state sebelumnya, sebuah reducer akan menyederhanakan komponen secara drastis.

Di Mana State Sebenarnya Berada

Terkadang masalahnya bukan pada bagaimana Anda menyimpan state, melainkan di mana. Kesalahan umum adalah melakukan hoisting state ke parent hanya karena state tersebut mungkin dibutuhkan di tempat lain. Jika hanya satu komponen leaf yang menggunakan suatu bagian state, simpanlah di sana. Ini adalah colocation, dan hal ini mengurangi blast radius dari perubahan. Jangan membuat parent melakukan re-render karena sebuah child terbuka