Skip to content

Bug React Key Ganda dari localStorage dan Cara Saya Membereskan

Adityo Guni Waluyo

Riwayat chat lama memicu error duplicate key di React. Akar masalahnya ID ganda plus penomoran lewat effect yang rapuh.

Ringkasan

Error key ganda di React muncul pas riwayat chat di-load ulang dari localStorage yang ternyata punya ID turn kembar. Akar masalahnya dua lapis: data lama tidak unik dan penomoran ID lewat effect terpisah yang timing-nya rapuh. Solusinya, setiap turn di-reindex jadi 0 sampai n-1 saat restore, lalu nextIdRef di-seed di effect yang sama.

Buka konsol, muncul error merah: Encountered two children with the same key. Yang bikin kesal bukan halamannya mati, tapi pesannya muncul pas saya justru sedang tidak menyentuh apa-apa. Skenarionya sederhana: saya membuka lagi halaman chat /ai di proyek my-apps, riwayat percakapan dimuat ulang dari localStorage, dan React langsung protes. Dugaan pertama saya sempat ngarah ke mana-mana, dari struktur data sampai curiga ada bug di React itu sendiri. Ternyata akar masalahnya ada di dua lapis sekaligus: data lama yang tersimpan menyimpan ID ganda, dan cara saya menomori ID baru justru memperbesar peluang tabrakan.

Dua lapis masalah: ID ganda dan penomoran yang rapuh

Lapis pertama ada di datanya. Riwayat chat lama yang tersimpan di localStorage ternyata punya ID turn yang tidak unik, misalnya dua turn sama-sama bernomor 2. Aturan React soal ini tegas: key adalah cara React mencocokkan item array dengan elemen yang sudah ada di layar, dan key harus unik antar sibling. Kalau key ganda, React tidak tahu item mana yang boleh dipertahankan, mana yang perlu digambar ulang. Dokumentasi resmi menjelaskan bahwa key memberi tahu React item array mana yang sesuai dengan komponen mana supaya bisa dicocokkan belakangan, dan key harus unik di antara saudaranya [1]. State komponen juga terikat posisi di render tree, jadi key yang kacau berarti identitas komponen yang kacau [6].

Lapis kedua ada di kode saya sendiri. Untuk menomori turn baru, saya memakai pola lama: sebuah nextIdRef yang di-update di dalam effect terpisah. Pola ini bekerja biasa saja sampai ada restore massal. Effect yang membaca ref di timing yang salah bisa memakai nilai basi, dan di situ duplikat lahir. Dokumentasi React sudah mengingatkan sejak awal: effect itu pintu darurat untuk sinkronisasi dengan sistem di luar React, bukan untuk memantau atau menransformasi state internal, sebab ketika effect berjalan, ia tidak tahu apa yang baru saja dilakukan pengguna [2]. Penomoran ID termasuk urusan internal, dan saya memaksanya lewat jalur yang salah.

Satu catatan kecil soal key berbasis index: aturan "jangan pakai index" itu bukan dogma absolut. Dokumentasi legacy React masih mengizinkan index sebagai pilihan terakhir kalau item tidak punya ID stabil dan urutannya tidak pernah berubah, tapi eksplisit melarangnya kalau urutan bisa berubah karena berdampak ke performa dan state komponen [5]. Data chat saya jelas berubah urut dan isinya, jadi index bukan pilihan.

Perbaikan dua lapis di commit c30eb3a

Di commit c30eb3a, saya menyentuh dua file: AiChatWorkspace.tsx dan ai-chat.ts [4]. Perbaikan pertama ada di fungsi pemuat riwayat: setiap turn yang di-restore dari localStorage di-reindex jadi urutan 0 sampai n-1. Data lama dengan ID ganda otomatis dibenahi saat masuk sesi, jadi tabrakan key mustahil terjadi dari sisi restore.

// ai-chat.ts — reindex saat restore
const stored = raw
  .filter(isStoredTurn)
  .slice(-HISTORY_MAX_TURNS)
  .map((t, i) => ({
    ...t,
    id: i, // legacy bisa punya id ganda -> selalu unik di sesi ini
    enhanced: typeof t.enhanced === "string" ? t.enhanced : "",
    sources: Array.isArray(t.sources) ? t.sources : [],
    followups: Array.isArray(t.followups) ? t.followups : [],
  }));

Perbaikan kedua lebih menarik: effect nextIdRef yang terpisah saya hapus total. Pemisahan itu justru sumber rapuhnya, karena effect restore dan effect penomoran jalan di timing yang tidak bisa saya jamin. Sekarang ID berikutnya di-seed langsung dari hasil restore, di dalam effect yang sama:

// AiChatWorkspace.tsx — seeding di effect restore
useEffect(() => {
  const stored = loadHistory();
  if (stored.length > 0) setTurns(stored);
  // id baru tidak boleh bentrok dengan id hasil restore
  nextIdRef.current = stored.reduce((m, t) => Math.max(m, t.id + 1), 0);
  setHydrated(true);
}, []);

Satu effect, satu sumber kebenaran. Nilai yang dipakai selalu hasil hitung dari data yang baru saja di-restore, bukan sisa render sebelumnya. Ini sejalan dengan cara kerja state React: fungsi set tidak mengubah nilai di render yang sedang berjalan, hasilnya baru terasa di render berikutnya, dan updater dieksekusi berurutan saat render itu terjadi [3]. Kalau pembaruan nilai bergantung pada asumsi soal timing, di situlah bug bertelur.

Kenapa perbaikan ini masuk akal

Bagian yang paling sering terlewat dari kasus semacam ini: memuat data dari localStorage memang sinkronisasi dengan sistem di luar React, jadi tempatnya memang di effect. Yang salah bukan keputusan pakai effect, tapi menaruh logika penomoran internal di effect yang kedua dan terpisah. Transformasi data sebaiknya dikerjakan tepat di titik data masuk, bukan diserahkan ke pengamat samping yang timing-nya mulai tergantung keberuntungan.

Ada juga pelajaran soal key dari sudut yang lebih luas. Saat merancang halaman yang menyimpan state ke media eksternal, saya sekarang menganggap data tersimpan itu tidak bisa dipercaya: bisa punya ID ganda, bisa bolong, bisa dibuat versi aplikasi lama dengan skema berbeda. Normalisasi saat masuk, satu sumber kebenaran untuk penomoran, dan sisanya jadi tanggung jawab React. Pola serupa saya alami dulu saat mengirim teks ke textarea terkontrol secara programatik, yang saya tulis di artikel menaklukkan textarea React: pemahaman kecil soal cara React melihat dunia sering menyelesaikan bug yang tampaknya tak masuk akal.

Sumber

Artikel terkait