Anda menyegarkan halaman dan layar tetap kosong. Atau mungkin klik tombol membuat kipas CPU Anda bekerja sangat keras. Kemudian konsol mengeluarkan peringatan yang menakutkan: Maximum update depth exceeded. React telah menginjak rem karena komponen Anda terjebak dalam loop tak terbatas. Ini adalah salah satu kesalahan paling umum dalam aplikasi React, dan biasanya berasal dari kesalahpahaman sederhana tentang kapan kode Anda sebenarnya dijalankan.

Untuk memperbaikinya, Anda perlu memahami momen tepat saat sebuah render menjadi re-render, dan mengapa perubahan state harus tetap berada di luar jalur render.

Bagaimana React Merender Komponen Anda

React membangun antarmuka pengguna dari komponen. Dalam React modern, komponen-komponen tersebut adalah fungsi. Setiap kali React perlu menampilkan komponen Anda di layar, ia cukup memanggil fungsi tersebut. Di dalam fungsi, Anda dapat menggunakan state untuk mengingat sesuatu di antara render. State memberi tahu React data mana yang merupakan milik komponen dan, yang terpenting, kapan sesuatu telah berubah dan UI perlu diperbarui.

Saat state berubah, React menjadwalkan render baru. Fungsi komponen dijalankan kembali, mengembalikan JSX baru, dan React memperbarui DOM agar sesuai. Dalam penggunaan normal, siklus ini tidak berbahaya. Anda mengklik tombol, sebuah event handler memperbarui state, React melakukan re-render sekali, dan pengguna melihat teks atau warna baru.

Kesalahan muncul ketika proses render itu sendiri memicu pembaruan state lainnya. Pembaruan state baru tersebut memicu render lain, yang kemudian memicu pembaruan state lainnya lagi. React menoleransi ini selama beberapa lusin siklus, lalu melemparkan error "maximum depth" untuk melindungi browser agar tidak membeku sepenuhnya.

Sekilas Tentang State

Sebelum membedah loop tersebut, ingat kembali cara kerja hook useState. Ia memberi Anda tepat dua hal: sebuah variabel yang menampung nilai saat ini, dan sebuah fungsi untuk mengubah nilai tersebut.

const MessageComponent = () => {
  const [message, setMessage] = useState('Welcome');

  return <h1>{message}</h1>;
};

Di sini, message adalah 'Welcome' pada render pertama. Jika Anda kemudian memanggil setMessage('Goodbye'), React mencatat perubahan tersebut, memanggil MessageComponent lagi, dan UI sekarang menampilkan "Goodbye." Semuanya baik-baik saja karena tidak ada apa pun di dalam body komponen itu sendiri yang memanggil setter secara otomatis. Loop dimulai ketika setter dijalankan selama fase render tanpa adanya event eksternal.

Memanggil SetState Secara Langsung di Dalam Body

Cara paling langsung untuk membuat loop tak terbatas adalah dengan memanggil fungsi setter state langsung di dalam body komponen. Karena body komponen dieksekusi pada setiap render, setter akan dijalankan pada setiap render. Pembaruan state baru tersebut menyebabkan render lainnya. Siklus ini berputar selamanya.

Berikut adalah contoh kesalahannya:

const Counter = () => {
  const [count, setCount] = useState(0);

  setCount(count + 1);

  return <div>{count}</div>;
};

Setiap kali Counter merender, ia menambah count. React merender lagi untuk menampilkan angka baru, melihat setCount(count + 1) lagi, dan menambahnya sekali lagi. Perbaikannya sederhana: jangan pernah memanggil setter state di level teratas komponen Anda selama proses render. Pembaruan state harus merespons event pengguna atau side effect, bukan merespons tindakan menggambar layar. Pindahkan pembaruan tersebut ke dalam event handler sebagai gantinya:

const Counter = () => {
  const [count, setCount] = useState(0);

  return <button onClick={() => setCount(count + 1)}>{count}</button>;
};

Satu-satunya pengecualian untuk memanggil setter selama render adalah saat Anda sedang menghitung state baru dari props, dan bahkan dalam kasus itu pun Anda harus menggunakan pola yang berbeda, seperti menurunkan (deriving) nilai secara langsung atau menggunakan useEffect dengan sengaja.

Mengirimkan Pemanggilan Fungsi, Bukan Referensi

Penyebab umum lainnya adalah kesalahan pengetikan JSX yang halus. Saat Anda memasang event handler, Anda perlu mengirimkan fungsi itu sendiri. Jika Anda secara tidak sengaja memanggil fungsi tersebut tepat di dalam JSX, fungsi itu akan langsung berjalan selama siklus render.

const Toggle = () => {
  const [isOn, setIsOn] = useState(false);

  const handleToggle = () => setIsOn(!isOn);

  return <button onClick={handleToggle()}>Toggle</button>;
};

Dengan menulis handleToggle() menggunakan tanda kurung, Anda tidak memberikan fungsi kepada React untuk dipanggil nanti saat pengguna mengklik. Anda memanggilnya saat ini juga saat React sedang membangun virtual DOM. Karena handleToggle memperbarui state, komponen melakukan re-render. Selama re-render tersebut, React melihat handleToggle() lagi dan memanggilnya lagi. Loop tidak pernah berakhir. Versi yang benar adalah dengan menghapus tanda kurung tersebut:

return <button onClick={handleToggle}>Toggle</button>;

Jika Anda perlu mengirimkan argumen, bungkus pemanggilan tersebut dalam fungsi anonim:

return <button onClick={() => handleToggle(true)}>Switch On</button>;

Perbedaan ini sering mengecoh pengembang berpengalaman sekalipun saat melakukan refactoring. Perhatikan penggunaan tanda kurung tersebut.

Jebakan Dependensi useEffect

Effects adalah tempat yang tepat untuk side effects seperti mengambil data, sinkronisasi dengan browser API, atau memanipulasi DOM secara manual. Namun useEffect berjalan setelah React melakukan commit render ke layar. Jika effect Anda memperbarui state, React akan melakukan re-render. Biasanya itu tidak masalah. Hal ini menjadi loop ketika effect berjalan setelah setiap render dan selalu memperbarui state yang sama.

Consider this broken pattern:

const UserProfile = () => {
  const [user, setUser] = useState({});

  useEffect(() => {
    setUser({ name: 'Ada', role: 'Admin' });
  });

  return <div>{user.name}</div>;
};

Because there is no dependency array, this effect runs after every single render. It sets user, which triggers a render. After that render, the effect runs again and sets user again. React detects the spiral and throws the error.

The fix is to tell React when the effect actually needs to run by supplying a proper dependency array. If the effect should only run once on mount, pass an empty array:

useEffect(() => {
  setUser({ name: 'Ada', role: 'Admin' });
}, []);

If the effect depends on a prop or a piece of state, include only that variable in the array. Be careful, though. Including a variable that changes every render will just recreate the same loop through a different door. For example, if you include an object literal in the dependencies and that object is recreated on every parent render, the effect will fire endlessly. In those cases, you may need to move the object creation outside the component or memoize it.

Practical Debugging Steps

When you hit this error, the stack trace can look overwhelming because React has already repeated the cycle dozens of times. Start by reading the top of the trace to find which component is named repeatedly. Then look for state setters in these three places:

  1. The main body of the component, outside any handler or hook.
  2. JSX event attributes where you might have written handler() instead of handler.
  3. useEffect hooks that lack a dependency array or depend on unstable references.

Temporarily comment out each state setter until the error stops. That tells you exactly which update is the culprit. If the setter is inside an effect, ask yourself whether you even need state there. Sometimes developers set local state from props inside an effect when they could simply use the prop directly in the JSX.

The Real Takeaway

The Maximum update depth error is not a mysterious React bug. It is a safety net. It means your component is trying to re-render itself instead of waiting for an external signal. Break the habit of treating renders as events that should produce more state. Treat renders as pure consequences of state, not as causes. Keep state updates inside event handlers, callbacks, or effects with carefully chosen dependencies, and you will never see this error again.