D
P
0
← Semua artikel Read in English

Next.js & React di Produksi

`react-hooks/set-state-in-effect`? 27 Error React Compiler di Next 16 dan Mana yang Layak Di-disable

· · 5 menit baca
`react-hooks/set-state-in-effect`? 27 Error React Compiler di Next 16 dan Mana yang Layak Di-disable

Ada momen aneh waktu upgrade framework: kodemu tidak berubah sedikit pun, aplikasinya jalan persis seperti kemarin, tapi tiba-tiba terminal penuh warna merah. Itu yang terjadi waktu saya menaikkan sebuah proyek klien ke Next 16 dengan React 19.2. Aturan lint bawaan React Compiler ikut aktif, dan sekali jalan lint saya dapat 27 error.

Yang bikin kepala pusing bukan jumlahnya. Tapi kenyataan bahwa sebagian error itu menunjuk ke kode yang, sejauh yang saya tahu, memang cara yang benar untuk menulisnya.

Gejalanya: error di kode yang tidak salah

Yang paling membingungkan datang dari tiga aturan yang sebelumnya tidak pernah saya temui:

react-hooks/set-state-in-effect
react-hooks/purity
react-hooks/incompatible-library

Yang paling ramai set-state-in-effect. Dia menembak pola yang isinya begini:

useEffect(() => {
  setTheme(window.localStorage.getItem("theme") ?? "system");
}, []);

Itu hidrasi preferensi dari localStorage. Nilainya tidak ada di server, jadi mau tidak mau baru bisa dibaca setelah komponen mount. Pola kembarannya juga kena:

const [mounted, setMounted] = useState(false);
 
useEffect(() => {
  setMounted(true);
}, []);

Penanda mount yang dipakai untuk menunda render bagian yang tidak boleh beda antara server dan klien. Dua-duanya pola kanonik yang muncul di ribuan codebase React, dan dua-duanya sekarang error.

30 menit yang saya buang untuk "melakukan yang benar"

Reaksi pertama saya adalah reaksi yang salah: saya anggap linternya benar dan saya yang malas. Jadi saya coba tulis ulang hidrasi localStorage itu "dengan benar" pakai useSyncExternalStore, karena secara teori memang itu jalur resmi untuk membaca sumber data eksternal tanpa efek.

Tiga puluh menit lebih hilang di situ. Yang saya dapat bukan kode yang lebih bersih, tapi kode yang lebih panjang, lebih sulit dibaca orang berikutnya, dan tetap harus punya getServerSnapshot terpisah yang isinya nilai default yang sama saja. Saya menukar tiga baris jelas dengan selusin baris pintar, cuma supaya linternya diam.

Di titik itu saya berhenti dan mengganti pertanyaannya. Bukan lagi "gimana caranya bikin aturan ini senang", tapi "aturan ini sebenarnya sedang melindungi saya dari apa".

Akar masalahnya: tiga aturan dengan alasan yang berbeda

Setelah dibaca satu per satu, ketiganya ternyata bukan satu jenis masalah.

react-hooks/set-state-in-effect khawatir soal render berganda. Set state di dalam efek memang bikin React render dua kali, dan sembilan dari sepuluh kali itu tanda ada state turunan yang seharusnya dihitung saja saat render. Tapi ada kasus di mana render kedua itu memang tujuannya: nilainya secara fisik tidak ada sebelum browser hidup. Hidrasi localStorage dan penanda mount masuk kategori ini. Aturannya benar soal mekanismenya, cuma tidak bisa tahu niat kita.

react-hooks/purity urusannya beda dan jauh lebih serius. Dia menembak pemanggilan Date.now() atau Math.random() di badan render. Ini bukan false positive. Render yang memanggil dua fungsi itu tidak murni, hasilnya berubah tiap kali dipanggil, dan React Compiler tidak bisa memoisasi apa pun di sekitarnya dengan aman. Kalau aturan ini menyala, kodenya memang bermasalah.

react-hooks/incompatible-library muncul dari pemakaian watch() milik react-hook-form. Library itu memang bekerja dengan cara yang tidak bisa dilacak compiler, jadi peringatannya jujur, tapi tidak ada yang bisa saya tulis ulang di sisi saya.

Pintu darurat yang belum diakui linter

React Compiler punya jalan keluar level fungsi: taruh direktif "use no memo" di atas badan fungsinya, dan compiler akan melewatkan komponen itu. Itu yang seharusnya saya pakai untuk kasus library tadi.

Masalahnya, di eslint-config-next versi 16.2.4 yang saya pakai, direktif itu belum dikenali oleh aturan lintnya. Compiler menghormatinya, linternya tidak. Jadi pintu daruratnya ada, tapi gagangnya belum tersambung.

Jadi tinggal satu opsi yang masuk akal: eslint-disable per baris, dengan alasan yang ditulis di komentarnya.

Perbaikannya: 27 jadi 0 dalam satu lintasan

Saya bagi 27 error itu jadi dua tumpukan.

20 perbaikan beneran. Semua error purity saya selesaikan dengan lazy initializer, memindahkan perhitungan yang tidak murni ke dalam fungsi yang cuma jalan sekali saat mount:

// sebelum: dihitung di badan render, jadi tiap render hasilnya beda
const [step, setStep] = useState(Math.round((TARGET - Date.now()) / INTERVAL));
 
// sesudah: lazy init, dievaluasi sekali saat state dibuat
const [step, setStep] = useState(() => Math.round((TARGET - Date.now()) / INTERVAL));

Sisanya pekerjaan rumah tangga biasa: beberapa <a href> internal yang belum dipindah ke next/link, dan setumpuk argumen tak terpakai yang sebenarnya sengaja ada. Yang terakhir itu urusan konfigurasi, bukan kode:

// eslint.config.mjs
rules: {
  "no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
}

Setelah itu argumen yang memang cuma placeholder cukup diberi awalan garis bawah dan linternya berhenti mengeluh soal sesuatu yang bukan bug.

7 disable dengan alasan. Sisanya adalah pola hidrasi dan mount yang tadi, plus panggilan watch() react-hook-form yang tidak ada bagiannya untuk saya tulis ulang. Untuk itu saya matikan aturannya per baris, dan saya wajibkan diri sendiri menulis kenapa:

useEffect(() => {
  // eslint-disable-next-line react-hooks/set-state-in-effect -- nilai ini tidak ada di server, hidrasi memang harus terjadi setelah mount
  setTheme(window.localStorage.getItem("theme") ?? "system");
}, []);

Komentar alasannya bukan formalitas. Enam bulan lagi, eslint-disable tanpa penjelasan tidak bisa dibedakan dari orang yang menyerah. Dengan penjelasan, orang berikutnya bisa menilai apakah alasannya masih berlaku.

Aturan jempol yang saya bawa keluar dari sesi ini

Yang paling berguna dari semua ini bukan angka 27 jadi 0, tapi satu garis yang akhirnya bisa saya tarik dengan jelas:

Linter baru itu bukan hakim, dia rekan kerja yang baru masuk dan belum kenal codebase kita. Kadang dia melihat sesuatu yang kita lewatkan, kadang dia belum paham kenapa sesuatu ditulis begitu. Tugas kita membedakan keduanya, bukan menurut ke semuanya.