Topik wawancara representatif

Wawancara Frontend: Bagaimana Cara Anda Mendebug dan Memperbaiki Stale Closure di React?

FrontendSulit
Tim Redaksi Offer.ccDipublikasikan Diperbarui

Pertanyaan

Sebuah komponen React 19.2 berlangganan ke pesanan langsung (live orders), tetapi callback-nya terus membaca tenant, filter, dan count awal. Jelaskan mengapa hal ini terjadi, prediksi bug yang muncul, dan perbaiki komponen tersebut tanpa menyambung ulang koneksi pada setiap perubahan state. Bandingkan dependensi effect, pembaruan state fungsional, useEffectEvent, ref, dan pembatalan asinkron.

Masalah dan Konteks yang Berlaku

Perhatikan dasbor React 19.2 yang berlangganan ke pesanan langsung berikut:

tsx
function LiveOrders({ tenantId }: { tenantId: string }) {
  const [filter, setFilter] = useState("all")
  const [count, setCount] = useState(0)

  useEffect(() => {
    const connection = connect(tenantId)

    connection.on("order", (order) => {
      if (matches(order, filter)) {
        setCount(count + 1)
      }
    })

    return () => connection.close()
  }, [])

  // Rendering and controls omitted.
}

Setelah pengguna mengubah filter, callback masih menerapkan "all". Beberapa pesanan yang masuk secara bersamaan (burst) dapat menyebabkan nilai count tertahan di 1. Jika komponen menerima tenantId yang berbeda, komponen tersebut tetap terhubung ke tenant pertama. Menambahkan setiap nilai yang direferensikan ke dalam daftar dependensi tampaknya memperbaiki kebaruan data (freshness), tetapi kemudian setiap perubahan count atau filter akan memutus dan membuat ulang koneksi.

Jelaskan mengapa kegagalan ini terjadi, bagaimana membuktikan nilai mana yang stale, dan bagaimana mempertahankan masa pakai (lifetime) langganan yang dimaksudkan sembari callback tetap mengamati nilai committed terbaru.

Ini adalah pertanyaan wawancara frontend yang realistis karena panduan wawancara React publik baik dalam bahasa Inggris maupun Mandarin secara eksplisit menguji stale closure, aturan Hook, dan dependensi useEffect. Keterampilan yang lebih mendalam bukanlah menghafal satu solusi perantara (workaround), melainkan memutuskan apakah suatu nilai harus me-restart sebuah Effect, berpartisipasi dalam transisi state, atau dibaca secara non-reaktif oleh callback milik Effect.

Apa yang Sedang Dievaluasi Pewawancara

Pertama, dapatkah kandidat menjelaskan mekanismenya secara akurat? Setiap render menerima sebuah snapshot dari props dan state. Fungsi-fungsi yang dibuat selama render tersebut membungkus (close over) snapshot tersebut. React tidak memutasi variabel lokal filter, count, atau tenantId setelah render berikutnya. Jika sistem eksternal menahan callback pertama, callback tersebut akan terus membaca nilai-nilai dari render pertama. Closure tersebut berperilaku normal; kontrak sinkronisasi yang dideklarasikan oleh Effect-lah yang tidak lengkap.

Kedua, dapatkah kandidat memisahkan empat kebutuhan yang berbeda?

KebutuhanAlat yang tepatApa yang diubahnya
Sumber daya eksternal harus mengikuti nilai reaktifDependensi EffectMembersihkan dan menyinkronkan ulang Effect
State berikutnya diturunkan dari state sebelumnyaPembaruan fungsional (functional updater)Menghitung dari antrean state React saat ini
Callback milik Effect membutuhkan nilai committed terbaru tanpa berlangganan ulanguseEffectEvent di React 19.2Membaca props dan state saat ini secara non-reaktif
Data mutable imperatif yang tidak dirender membutuhkan identitas stabilRefMenyimpan nilai mutable yang disinkronkan secara manual

Ketiga, dapatkah kandidat menghindari perbaikan palsu? useCallback(fn, []) mempertahankan identitas fungsi tetapi juga mempertahankan closure render pertamanya. Menonaktifkan (suppressing) exhaustive-deps menyembunyikan bukti alih-alih mengubah semantik. Membuat ulang WebSocket untuk setiap inkremen counter mungkin menghasilkan data baru, tetapi itu adalah masa pakai sumber daya yang salah.

Terakhir, dapatkah kandidat mencakup cleanup dan pengurutan asinkron? Closure yang baru tidak membatalkan request lama, tidak mencegah respons yang datang tidak berurutan, atau tidak menghapus event listener yang bocor. Hal-hal tersebut adalah kewajiban konkurensi dan siklus hidup yang terpisah.

Pertanyaan Klarifikasi Sebelum Menjawab

  • Nilai mana yang menentukan identitas langganan? Jika tenantId memilih stream di sisi server, mengubahnya harus menyambung ulang koneksi. Filter tampilan biasanya tidak perlu demikian.
  • Apakah event historis harus dievaluasi ulang saat filter berubah? Hal ini menentukan apakah filter hanya berada di dalam callback atau juga memicu query atau langganan baru.
  • Apakah callback menghitung nilai dari state sebelumnya? Menaikkan nilai count harus menggunakan functional updater meskipun callback tersebut membaca nilai baru.
  • Versi React mana yang digunakan? useEffectEvent tersedia di React 19.2. Pada versi yang lebih lama, ref yang disinkronkan dengan hati-hati dapat menjadi fallback kompatibilitas.
  • Bisakah API eksternal mengganti listener-nya tanpa menyambung ulang koneksi? Beberapa library mengekspos operasi subscribe dan updateHandler secara terpisah, yang mungkin mendukung masa pakai minimal yang berbeda.
  • Apakah request asinkron dipicu oleh callback? Jika ya, tentukan pembatalan atau pemeriksaan generasi request selain menangani nilai yang tertangkap (captured values).
  • Apakah Strict Mode diaktifkan dalam pengembangan? Siklus setup dan cleanup tambahannya dapat mengekspos cleanup yang hilang, tetapi tidak menciptakan stale closure.

Kerangka Jawaban 30 Detik

“Callback React membaca snapshot props dan state dari render yang membuatnya. Di sini Effect hanya berjalan sekali, sehingga koneksi mempertahankan tenant, filter, dan count pertama. Saya akan membuat langganan bergantung pada tenantId, karena nilai tersebut mengubah sumber daya mana yang kita sambungkan. Saya akan memperbarui count dengan setCount(current => current + 1), karena nilainya diturunkan dari state sebelumnya. Agar callback pesanan dapat membaca filter terbaru tanpa menyambung ulang koneksi, pada React 19.2 saya akan menempatkan logika non-reaktif tersebut di useEffectEvent dan memanggilnya dari listener milik Effect. Saya tidak akan menonaktifkan linter dependensi atau menggunakan useCallback([]) sebagai solusi kebaruan data. Ref adalah opsi kompatibilitas manual untuk data mutable yang tidak dirender, dan hasil asinkron tetap memerlukan pembatalan atau versioning.”

Pembahasan Mendalam Langkah demi Langkah

Langkah satu: modelkan setiap render sebagai snapshot yang tidak dapat diubah (immutable).

Selama render pertama, asumsikan nilai-nilai ini adalah:

text
tenantId = "tenant-a"
filter = "all"
count = 0

Effect membuat koneksi dan callback yang mereferensikan variabel lokal persis seperti di atas. Kemudian, setFilter("paid") menghasilkan render lain dengan variabel filter baru. Ini tidak mengubah variabel yang ditangkap oleh callback asli. Karena daftar dependensi yang kosong menyatakan bahwa Effect tidak perlu disinkronkan lagi, koneksi eksternal terus memegang callback asli.

Hal itu memprediksi ketiga bug tanpa perlu menebak:

  1. matches(order, filter) terus menggunakan "all".
  2. Setiap event yang cocok memanggil setCount(0 + 1), sehingga event berulang mengganti state dengan 1 alih-alih mengakumulasikan inkremen.
  3. Koneksi tetap terasosiasi dengan "tenant-a" setelah perubahan prop.

Log debugging yang berguna mencakup nomor urut render, nilai yang terlihat selama render, dan nilai yang terlihat di dalam callback yang ditahan. Catat ID koneksi secara terpisah. Ini mengungkapkan apakah callback-nya stale, langganan tidak me-restart, atau server mengirimkan data yang tidak diharapkan.

Langkah dua: klasifikasikan setiap pembacaan berdasarkan semantik sebelum mengedit dependensi.

Tanyakan mengapa Effect membaca setiap nilai:

  • tenantId menentukan stream eksternal mana yang disinkronkan. Nilai ini reaktif dan termasuk dalam daftar dependensi.
  • count hanya diperlukan untuk menghitung count berikutnya. Ganti pembacaan dengan functional updater, sehingga tidak perlu lagi ditangkap.
  • filter memengaruhi cara event mendatang ditangani, tetapi mengubahnya tidak boleh merusak koneksi tenant. Listener memerlukan pembacaan nilai terbaru tanpa membuat langganan reaktif terhadap filter.

Klasifikasi ini mencegah dua ekstrem: daftar dependensi kosong dan daftar dependensi yang me-restart sumber daya mahal pada setiap perubahan terkait render.

Langkah tiga: perbaiki transisi state dengan functional updater.

Ubah ini:

tsx
setCount(count + 1)

menjadi ini:

tsx
setCount((current) => current + 1)

React mengantrekan updater dan meneruskan state saat ini yang tertunda saat render berikutnya dihitung. Oleh karena itu, sepuluh event akan mengakumulasikan sepuluh inkremen bahkan jika callback-nya didaftarkan lebih awal atau React menggabungkan (batches) pembaruan tersebut. Ini hanya memperbaiki dependensi count. Ini tidak membuat filter atau tenantId menjadi baru.

Langkah empat: deklarasikan dependensi yang benar-benar menyinkronkan ulang sumber daya.

Identitas koneksi mencakup tenantId, sehingga Effect harus bergantung padanya. Pada perubahan tenant, React pertama-tama menjalankan cleanup sebelumnya dan kemudian menyiapkan koneksi baru. Cleanup harus menghapus listener atau menutup koneksi lama sehingga event dari tenant sebelumnya tidak dapat memperbarui layar saat ini.

Menambahkan filter dan count secara teknis akan memberikan nilai terbaru untuk setiap callback baru, tetapi itu juga akan mendefinisikan ulang masa pakai koneksi. Pembaruan counter akan menyebabkan pemutusan dan penyambungan ulang koneksi. Hal itu dapat menghilangkan event, menduplikasi event selama cleanup yang tumpang tindih, mereset state kursor di sisi server, dan menciptakan beban yang tidak perlu. Daftar dependensi adalah spesifikasi perilaku, bukan petunjuk performa yang diedit hanya sampai linter berhenti memberi peringatan.

Langkah lima: gunakan Effect Event untuk pembacaan non-reaktif terbaru di React 19.2.

Komponen yang diperbaiki dapat memisahkan masa pakai koneksi dan kebaruan callback:

tsx
function LiveOrders({ tenantId }: { tenantId: string }) {
  const [filter, setFilter] = useState("all")
  const [count, setCount] = useState(0)

  const onOrder = useEffectEvent((order: Order) => {
    if (matches(order, filter)) {
      setCount((current) => current + 1)
    }
  })

  useEffect(() => {
    const connection = connect(tenantId)
    connection.on("order", onOrder)

    return () => connection.close()
  }, [tenantId])

  // Rendering and controls omitted.
}

onOrder selalu mengamati filter committed terbaru, sementara Effect tetap hanya menyinkronkan ulang saat tenantId berubah. Effect Event dipanggil dari Effect atau kode yang terhubung ke Effect tersebut, seperti listener yang didaftarkannya. Ini bukan pengganti event handler umum, tidak boleh diteruskan secara sembarangan melalui pohon komponen, dan tidak boleh digunakan untuk menyembunyikan nilai yang seharusnya me-restart sinkronisasi.

Jangan tambahkan Effect Event ke daftar dependensi. Aturan linting React saat ini memahami peran non-reaktifnya. Jika linter melaporkan nilai yang berbeda, perlakukan peringatan tersebut sebagai masukan desain dan ubah struktur kode alih-alih menonaktifkan aturannya.

Langkah enam: pahami kapan ref tepat digunakan dan apa biayanya.

Sebelum React 19.2, pola kompatibilitas umum menyimpan nilai terbaru di dalam ref:

tsx
const filterRef = useRef(filter)

useEffect(() => {
  filterRef.current = filter
}, [filter])

useEffect(() => {
  const connection = connect(tenantId)
  connection.on("order", (order) => {
    if (matches(order, filterRef.current)) {
      setCount((current) => current + 1)
    }
  })

  return () => connection.close()
}, [tenantId])

Objek ref stabil di seluruh render, dan mengubah current tidak memicu rendering. Itu membuat ref tepat untuk nilai imperatif seperti handler terbaru, timer ID, atau handle API eksternal yang tidak ditampilkan secara langsung. Biayanya adalah sinkronisasi manual: linter dependensi tidak dapat membuktikan bahwa filterRef.current diperbarui dengan benar, dan Effect sinkronisasi yang terlewat akan secara diam-diam memunculkan kembali perilaku stale. Jangan memindahkan state yang ditampilkan ke dalam ref hanya untuk menghindari render, dan jangan membaca atau menulis ref selama proses rendering kecuali untuk pola inisialisasi yang terdokumentasi.

Langkah tujuh: pertahankan useCallback dan memoisasi pada peran yang tepat.

useCallback menyimpan cache identitas fungsi hingga salah satu dependensinya berubah. Ini berguna ketika child component yang dimemoisasi atau API eksternal peduli dengan identitas. Fungsi ini tidak menyediakan nilai terbaru dengan sendirinya:

tsx
const onOrder = useCallback((order: Order) => {
  if (matches(order, filter)) {
    setCount((current) => current + 1)
  }
}, [])

Versi ini masih menangkap filter awal. Menambahkan [filter] memperbarui fungsi tersebut, tetapi koneksi eksternal kemudian harus mengganti listener-nya dengan benar. Jika penghapusan listener memerlukan identitas fungsi yang sama, setup dan cleanup harus menggunakan instans callback yang sama untuk render tersebut. Memoisasi menjawab pertanyaan identitas; ia tidak menjawab pertanyaan masa pakai sinkronisasi.

Langkah delapan: selesaikan pengurutan asinkron secara terpisah.

Misalkan filter terbaru memulai sebuah request. Request "all" mungkin selesai setelah request "paid" yang lebih baru dan menimpa hasilnya. Bahkan callback dengan filter terbaru tidak dapat mencegah request lama tersebut untuk selesai. Effect harus membatalkan pekerjaan usang dengan AbortController jika didukung, atau mengaitkan setiap request dengan generasi yang meningkat secara monoton dan hanya meng-commit generasi terbaru.

Cleanup juga harus simetris:

  • menutup koneksi persis yang dibuat oleh setup tersebut;
  • menghapus listener persis yang didaftarkan oleh setup tersebut;
  • membersihkan interval dan timeout;
  • membatalkan atau menganulir pekerjaan asinkron yang tertunda;
  • memastikan callback yang terlambat tidak menimbulkan dampak berbahaya setelah cleanup.

Urutan setup-cleanup-setup khusus pengembangan di Strict Mode adalah bukti yang berguna di sini. Koneksi duplikat menunjukkan cleanup yang tidak lengkap. Itu bukan bukti bahwa Strict Mode yang menyebabkan stale closure.

Langkah sembilan: verifikasi perilaku dengan mengubah satu dimensi pada satu waktu.

PengujianHasil yang diharapkan
Mengirimkan tiga pesanan yang cocok sebelum React merender kembaliNilai count bertambah tiga
Mengubah filter, lalu mengirimkan pesananListener menerapkan filter baru tanpa menyambung ulang koneksi
Mengubah tenantIdKoneksi lama ditutup sekali; tenant baru tersambung sekali
Mengirimkan event dari koneksi lama setelah cleanupUI saat ini tidak berubah
Menyelesaikan dua request dalam urutan terbalikHanya request terbaru yang boleh di-commit
Menjalankan di bawah Strict Mode dalam pengembanganSetup dan cleanup tetap simetris; tidak ada listener duplikat
Mengaktifkan kembali aturan lint dependensi HookTidak ada peringatan dependensi yang diabaikan atau tidak dijelaskan

Di lingkungan produksi, lacak jumlah setup dan cleanup koneksi, ID koneksi aktif, pergantian tenant, event terlambat yang diabaikan (late-event drops), dan request yang dibatalkan. Jangan mencatat payload pesanan pribadi hanya untuk mendiagnosis kebaruan callback.

Contoh Jawaban Berkualitas Tinggi

“Saya akan memulainya dari model render React. Setiap render mendapatkan snapshot dari props dan state, dan fungsi yang dibuat dalam render tersebut membungkus snapshot tersebut. Karena Effect ini memiliki daftar dependensi kosong, koneksi eksternal mempertahankan callback dari render pertama. Oleh karena itu, ia melihat tenant, filter, dan count pertama.

Saya akan mengklasifikasikan setiap nilai sebelum mengubah daftar dependensi. tenantId memilih sumber daya eksternal, sehingga ia termasuk dalam dependensi dan perubahannya harus menutup koneksi lama serta membuka yang baru. count hanya digunakan untuk menurunkan state berikutnya, jadi saya akan memanggil setCount(current => current + 1). Itu membuat pembaruan burst tersusun dari antrean state React. filter terbaru diperlukan saat listener pesanan milik Effect berjalan, tetapi tidak boleh me-restart koneksi. Karena aplikasi ini menggunakan React 19.2, saya akan menempatkan logika pemfilteran di useEffectEvent dan mendaftarkan Effect Event tersebut dari Effect yang hanya bergantung pada tenantId.

Saya tidak akan menonaktifkan exhaustive-deps, karena daftar dependensi mendeskripsikan sinkronisasi. Saya tidak akan menggunakan useCallback([]) sebagai solusi kebaruan data, karena ia mempertahankan closure awal. Pada versi React yang lebih lama, ref yang disinkronkan dari filter dapat menjadi fallback kompatibilitas, tetapi itu bersifat manual dan tidak terlihat oleh analisis dependensi.

Terakhir, saya akan menguji inkremen burst, perubahan filter tanpa menyambung ulang koneksi, perubahan tenant dengan tepat satu cleanup dan setup, event yang tiba dari koneksi yang ditutup, dan Strict Mode pengembangan. Jika callback meluncurkan request, saya juga akan membatalkan atau memberi versi pada request yang usang, karena kebaruan closure saja tidak mencegah hasil yang tidak berurutan.”

Kesalahan Umum

  • Menyebut setiap closure sebagai bug → callback memang diharapkan menangkap snapshot render → identifikasi ketidaksesuaian antara callback yang ditahan dan sinkronisasi yang dimaksudkan.
  • Menonaktifkan exhaustive-deps kode berjanji bahwa pembacaan reaktif tidak pernah penting padahal tetap menggunakannya → ubah kode sehingga daftar dependensi menggambarkan Effect secara jujur.
  • Menambahkan setiap nilai state ke dependensi → kebaruan dipulihkan dengan membuat ulang langganan mahal setelah setiap pembaruan → pisahkan identitas sumber daya dari pembacaan callback terbaru.
  • Menggunakan useCallback(fn, []) identitas stabil juga membekukan closure pertama → berikan useCallback dependensi yang benar atau gunakan alat yang sesuai dengan semantik yang diinginkan.
  • Mengganti state dengan ref → penulisan ref tidak memicu render dan dapat menyimpang dari UI yang terlihat → pertahankan data yang ditampilkan di state dan khususkan ref untuk data mutable imperatif.
  • Menggunakan useEffectEvent untuk menyembunyikan dependensi nyata → Effect tidak lagi bereaksi saat sumber daya eksternalnya seharusnya berubah → pertahankan nilai-nilai penentu sumber daya di dalam daftar dependensi.
  • Memperbaiki filter tetapi tetap menggunakan setCount(count + 1) event burst masih menggantikan nilai dari count yang tertangkap → gunakan functional updater untuk state yang diturunkan dari state sebelumnya.
  • Mengabaikan cleanup dan pengurutan request → listener yang bocor dan respons yang terlambat tetap merusak tampilan layar → hapus, tutup, batalkan, atau beri versi pada setiap operasi sesuai dengan siklus hidupnya.

Pertanyaan Lanjutan dan Jawabannya

Pertanyaan lanjutan 1: Mengapa setTimeout di dalam click handler melihat nilai state yang lama?

Callback timeout milik render di mana klik itu terjadi. Memperbarui state menjadwalkan render baru; ini tidak memutasi variabel state lokal handler tersebut. Jika aksi yang tertunda tersebut seharusnya melaporkan nilai pada saat klik, snapshot tersebut sudah benar. Jika harus menggunakan nilai committed terbaru, modelkan persyaratan tersebut secara eksplisit dengan Effect Event dalam kode terkait Effect atau ref yang dikelola dengan hati-hati untuk callback imperatif.

Pertanyaan lanjutan 2: Mengapa tidak memasukkan filter ke dalam daftar dependensi Effect?

Itu hanya benar jika perubahan filter memang harus menyinkronkan ulang sistem eksternal. Jika langganan server itu sendiri spesifik terhadap filter, sertakan filter dan lakukan koneksi ulang. Dalam skenario ini, stream tenant tetap sama dan filter adalah kebijakan pemrosesan event lokal, sehingga menyambung ulang koneksi akan memberikan masa pakai yang salah. Nyatakan asumsi produk dan API sebelum memilih.

Pertanyaan lanjutan 3: Apakah useEffectEvent menggantikan event handler biasa?

Tidak. Klik tombol biasanya ditangani oleh event handler yang dibuat selama rendering. Effect Event ditujukan untuk logika non-reaktif yang dipanggil oleh Effect atau kode yang terhubung dengannya, seperti timer atau listener langganan. Ini harus tetap lokal untuk Effect yang menggunakannya dan tidak boleh menjadi mekanisme pengiriman callback umum.

Pertanyaan lanjutan 4: Bisakah functional updater menyelesaikan setiap stale closure?

Tidak. Ini hanya menyelesaikan transisi state yang diturunkan dari nilai sebelumnya milik state yang sama. Ini tidak dapat membuat prop, variabel state lain, atau konfigurasi eksternal menjadi baru. Dalam contoh ini, functional updater memperbaiki inkremen count tetapi tidak memperbaiki koneksi tenant atau pembacaan filter.

Pertanyaan lanjutan 5: Kapan ref lebih disukai daripada state?

Gunakan ref ketika sebuah nilai harus bertahan di seluruh render, perubahannya tidak boleh merender UI, dan nilainya digunakan secara imperatif: timer ID, node DOM, handle koneksi, atau cell nilai terbaru untuk kompatibilitas. Jika output yang dirender bergantung padanya, gunakan state. Ref mengalihkan tanggung jawab kebaruan data ke developer, jadi dokumentasikan dan uji titik sinkronisasinya.

Pertanyaan lanjutan 6: Bagaimana Strict Mode dapat membantu mendebug masalah ini?

Strict Mode di lingkungan pengembangan menjalankan siklus setup dan cleanup tambahan untuk Effect. Jika dua listener atau koneksi tetap tertinggal, cleanup tidak lengkap atau menggunakan identitas callback yang salah. Setelah cleanup benar, siklus ekstra tersebut seharusnya menyisakan satu langganan aktif. Snapshot yang stale itu sendiri mengikuti semantik closure JavaScript normal di kedua mode.

Pertanyaan lanjutan 7: Bagaimana jika sebuah event terpicu di antara cleanup dan koneksi baru?

Tentukan jaminan pengiriman sistem eksternal. Klien mungkin memerlukan kursor yang dapat dilanjutkan (resumable cursor), nomor urut, replay window, atau snapshot yang diikuti oleh stream. Perubahan dependensi React dapat mengelola masa pakai lokal, tetapi tidak dapat menjamin pengiriman tanpa data hilang (lossless) di seluruh proses koneksi ulang server. Itu adalah persyaratan protokol dan harus diuji secara terpisah.

Pertanyaan lanjutan 8: Bagaimana cara mencegah fetch yang lebih lama menimpa hasil yang lebih baru?

Batalkan request usang selama cleanup Effect saat API mendukung pembatalan. Jika tidak, tetapkan sebuah generasi untuk setiap request dan perbarui state hanya jika generasi yang selesai tersebut masih merupakan generasi saat ini. Ref nilai terbaru saja tidak membatalkan pekerjaan jaringan dan tidak boleh dihadirkan sebagai jaminan pengurutan.

Sumber publik

Pertanyaan terkait