Pesan Error dan Maknanya
Error ini muncul dalam konsol browser atau terminal build React dengan pesan lengkap sebagai berikut:
Warning: An update to `ComponentName` inside a test was not wrapped in act(...).
When testing, code that resolves React updates should be wrapped into act(...):
act(() => {
/* fire events that result in state updates */
});
/* assert on the output */
Atau dalam konteks runtime production/development:
Warning: Calling setState synchronously within an effect can trigger cascading renders.
This happens when an effect updates state, which triggers another effect,
which updates state again — leading to an infinite loop of renders.
Secara sederhana, pesan ini memberi tahu bahwa kamu memanggil setState di dalam sebuah useEffect, dan pemanggilan itu secara sinkron memicu re-render, yang kemudian menjalankan useEffect lagi, yang memanggil setState lagi — menciptakan siklus berantai (cascading renders). React mendeteksi pola ini karena berpotensi menyebabkan infinite loop atau setidaknya performa yang sangat buruk akibat render cycle yang tidak terkendali.
Error ini muncul karena React, terutama sejak versi 18 dengan fitur Concurrent Mode dan Strict Mode yang lebih ketat, memvalidasi bahwa setiap pemanggilan setState yang terjadi sebagai efek samping (side effect) harus dikelola dengan benar. Ketika sebuah useEffect memanggil setState tanpa dependency array yang tepat, atau ketika nilai state yang di-set selalu berubah (misalnya selalu membuat objek baru), maka setiap render memicu effect baru, yang memicu setState baru, yang memicu render baru — begitu seterusnya. React menangkap pola berbahaya ini dan mengeluarkan peringatan agar developer segera memperbaiki arsitektur state management-nya.
Contoh kode yang memicu error ini:
function ProductList() {
const [products, setProducts] = useState([]);
const [filteredProducts, setFilteredProducts] = useState([]);
useEffect(() => {
fetchProducts().then(data => setProducts(data));
}, []);
useEffect(() => {
// BUG: setFilteredProducts dipanggil setiap products berubah,
// tapi hasilnya selalu referensi baru → effect jalan lagi → cascading
setFilteredProducts(products.filter(p => p.active));
}, [products]); // products berubah → setState → render → effect lagi
}
Akar Masalahnya
Berikut adalah root cause paling sering menyebabkan error ini, diurutkan dari yang paling banyak ditemui di lapangan:
1. Dependency array yang tidak tepat pada useEffect — Ini adalah penyebab nomor satu. Kamu menempatkan state atau prop yang berubah referensinya di dependency array, lalu di dalam effect tersebut kamu memanggil setState yang mengubah state tersebut (secara langsung atau tidak langsung). Setiap kali dependency berubah, effect berjalan, setState dipanggil, re-render terjadi, dependency "berubah" lagi (karena referensi baru), dan siklus berulang. Cara memastikan: periksa setiap useEffect yang memanggil setState — apakah salah satu dependency-nya adalah state yang juga di-update oleh effect tersebut?
2. Meng-set state dengan referensi baru pada setiap render — Meskipun secara logika nilai state "sama", jika kamu me-return objek baru, array baru, atau fungsi baru setiap kali effect berjalan, React menganggap itu sebagai perubahan karena perbandingan referensi (shallow equality) gagal. Cara memastikan: log nilai state sebelum dan sesudah setState — jika referensinya berbeda padahal kontennya identik, inilah penyebabnya.
3. Dua atau lebih effect yang saling memicu (chained effects) — Effect A meng-set state X, state X ada di dependency effect B, effect B meng-set state Y, dan state Y ada di dependency effect A. Ini menciptakan cascading renders. Cara memastikan: gambar graf dependensi antar effect — jika ada siklus (cycle), kamu mengalami masalah ini.
4. Memanggil setState tanpa kondisi di dalam effect — Effect berjalan setiap kali dependency berubah, dan di dalamnya kamu selalu memanggil setState tanpa guard condition (pengecekan apakah update benar-benar diperlukan). Cara memastikan: lihat apakah ada if statement atau pengecekan sebelum setState di dalam effect. Jika tidak ada, setState dipanggil di setiap eksekusi effect.
5. Menggunakan callback atau fungsi yang tidak di-memoize sebagai dependency — Fungsi yang didefinisikan di dalam komponen body akan memiliki referensi baru setiap render. Jika fungsi ini menjadi dependency effect yang memanggil setState, effect terus-menerus di-trigger. Cara memastikan: periksa apakah ada fungsi di dependency array yang tidak di-wrap dengan useCallback.
Solusi Bertahap
Langkah 1: Identifikasi effect yang bermasalah
Buka konsol browser dan cari warning yang muncul. React biasanya menyertakan component stack trace. Tandai komponen dan effect yang terlibat.
# Jika menggunakan React DevTools, aktifkan "Highlight updates when components render"
# untuk melihat komponen mana yang re-render berlebihan
Output yang diharapkan: Kamu bisa mengidentifikasi tepat di komponen dan baris mana setState yang bermasalah dipanggil.
Langkah 2: Periksa dependency array
Bandingkan dependency array effect dengan state yang di-set di dalamnya. Jika ada tumpang tindih, itulah sumber cascading renders.
// SEBELUM (bermasalah)
useEffect(() => {
setFilteredProducts(products.filter(p => p.active));
}, [products]); // products berubah → setState → render → effect → setState → ...
// SESUDAH (fixed — hitung derived state tanpa useEffect)
function ProductList({ products }) {
const filteredProducts = products.filter(p => p.active);
// Tidak perlu useState, tidak perlu useEffect
}
Output yang diharapkan: Warning menghilang dari konsol, dan komponen hanya re-render ketika products benar-benar berubah secara substantif.
Langkah 3: Konversi derived state ke computed values
Ini adalah fix paling fundamental. Banyak kasus "setState in effect" sebenarnya adalah derived state yang seharusnya dihitung langsung saat render, bukan disimpan sebagai state terpisah.
// SEBELUM (bermasalah)
const [data, setData] = useState(null);
const [processedData, setProcessedData] = useState(null);
useEffect(() => {
fetchData().then(setData);
}, []);
useEffect(() => {
if (data) {
setProcessedData(transform(data)); // cascading!
}
}, [data]);
// SESUDAH (fixed)
const [data, setData] = useState(null);
const processedData = data ? transform(data) : null; // computed, bukan state
useEffect(() => {
fetchData().then(setData);
}, []);
Output yang diharapkan: Satu efek samping (fetch) dikelola oleh satu effect. Derived value dihitung secara deklaratif. Tidak ada cascading render.
Langkah 4: Tambahkan guard condition jika setState tetap diperlukan
Untuk kasus di mana setState dalam effect memang diperlukan (misalnya sinkronisasi dengan external system), tambahkan pengecekan untuk menghindari update yang tidak perlu.
// SEBELUM (bermasalah)
useEffect(() => {
const subscription = externalStore.subscribe(() => {
setSyncedState(externalStore.getSnapshot()); // selalu dipanggil
});
return () => subscription.unsubscribe();
}, []);
// SESUDAH (fixed)
useEffect(() => {
const subscription = externalStore.subscribe(() => {
const newValue = externalStore.getSnapshot();
setSyncedState(prev => {
if (Object.is(prev, newValue)) return prev; // guard: skip jika sama
return newValue;
});
});
return () => subscription.unsubscribe();
}, []);
Output yang diharapkan: Effect tetap berjalan saat subscription mengirim data, tapi setState hanya memicu re-render jika nilai benar-benar berubah.
Langkah 5: Gunakan useSyncExternalStore untuk external state
React 18 menyediakan hook khusus untuk menyinkronkan external store tanpa risiko cascading renders.
import { useSyncExternalStore } from 'react';
function useExternalData(store) {
const snapshot = useSyncExternalStore(
store.subscribe, // subscribe callback
store.getSnapshot, // get snapshot
store.getServerSnapshot // for SSR
);
return snapshot;
}
Output yang diharapkan: Sinkronisasi external store ditangani secara native oleh React, menghilangkan kebutuhan manual setState dalam effect.
Verifikasi solusi berhasil
Jalankan langkah-langkah verifikasi berikut:
# 1. Buka React DevTools, aktifkan "Record why each component rendered"
# Re-render harus hanya terjadi ketika props/state benar-benar berubah
# 2. Jalankan test suite
npm test -- --coverage
# 3. Periksa konsol browser — tidak ada warning tentang cascading renders
Supaya Tidak Terulang
Fix permanen berarti kita mengubah arsitektur kode agar masalah ini secara struktural tidak bisa muncul lagi, bukan sekadar menambal gejalanya.
Konfigurasi ESLint yang mencegah error kembali
Pasang dan konfigurasi eslint-plugin-react-hooks secara ketat. Plugin ini akan memperingatkan kamu saat menulis pattern yang berisiko.
// .eslintrc.json
{
"plugins": ["react-hooks"],
"rules": {
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "error"
}
}
Dengan konfigurasi ini, ESLint akan meng-flag setiap effect yang memanggil setState tetapi tidak mencantumkan dependency yang tepat, atau setiap kasus di mana state yang di-set juga menjadi dependency effect tersebut.
Best practice arsitektural
Pertama, hindari useState untuk derived state. Jika sebuah nilai bisa dihitung dari state atau props yang sudah ada, jangan simpan sebagai state terpisah. Hitung langsung di render body atau gunakan useMemo jika komputasinya mahal:
// Best practice: derived state sebagai computed value
function SearchResults({ items, query }) {
const filteredItems = useMemo(
() => items.filter(item => item.name.includes(query)),
[items, query]
);
// filteredItems bukan state — tidak butuh useEffect untuk meng-update
}
Kedua, pisahkan event handler dari effect. Jika setState harus terjadi sebagai respons terhadap user action, taruh di event handler, bukan di effect:
// SEBELUM: setState in effect sebagai respons terhadap user action
useEffect(() => {
if (selectedId) {
setDetails(fetchDetailsSync(selectedId)); // salah: ini应该在 event handler
}
}, [selectedId]);
// SESUDAH: setState di event handler
function handleSelect(id) {
setSelectedId(id);
setDetails(fetchDetailsSync(id)); // langsung di handler, tanpa effect
}
Ketiga, gunakan reducer pattern untuk state yang saling bergantung. Ketika beberapa state perlu di-update secara bersamaan, useReducer memungkinkan kamu mengelola transisi state secara atomik tanpa cascading effect:
function reducer(state, action) {
switch (action.type) {
case 'FETCH_SUCCESS':
return { ...state, data: action.payload, processed: transform(action.payload) };
case 'FILTER':
return { ...state, filter: action.payload, processed: transform(state