Вы обновляете страницу, а экран остается пустым. Или, возможно, клик по кнопке заставляет ваш вентилятор процессора работать на пределе возможностей. А затем консоль выдает пугающее предупреждение: Maximum update depth exceeded. React резко нажал на тормоза, потому что ваш компонент попал в бесконечный цикл. Это одна из самых распространенных ошибок в приложениях на React, и обычно она возникает из-за простого непонимания того, когда именно выполняется ваш код.

Чтобы исправить это, нужно понять точный момент, когда рендер превращается в ререндер, и почему изменения состояния не должны происходить внутри пути рендеринга.

Как React рендерит ваш компонент

React строит пользовательские интерфейсы из компонентов. В современном React такими компонентами являются функции. Каждый раз, когда React нужно отобразить ваш компонент на экране, он просто вызывает эту функцию. Внутри функции вы можете использовать состояние (state), чтобы сохранять данные между рендерами. Состояние сообщает React, какие данные принадлежат компоненту и, что крайне важно, когда что-то изменилось и интерфейс требует обновления.

Когда состояние меняется, React планирует новый рендер. Функция компонента запускается снова, возвращает новый JSX, и React обновляет DOM в соответствии с ним. При обычном использовании этот цикл безвреден. Вы нажимаете кнопку, обработчик события обновляет состояние, React выполняет один ререндер, и пользователь видит новый текст или цвет.

Ошибка возникает, когда сам рендер инициирует очередное обновление состояния. Это новое обновление состояния вызывает еще один рендер, который, в свою очередь, вызывает еще одно обновление состояния. React допускает это в течение нескольких десятков циклов, а затем выдает ошибку о превышении максимальной глубины, чтобы защитить браузер от полного зависания.

Краткий обзор состояния

Прежде чем разбирать цикл, вспомните, как работает хук useState. Он дает вам ровно две вещи: переменную, хранящую текущее значение, и функцию для изменения этого значения.

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

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

Здесь message равно 'Welcome' при первом рендере. Если позже вы вызовете setMessage('Goodbye'), React зафиксирует изменение, снова вызовет MessageComponent, и интерфейс покажет "Goodbye". Все в порядке, потому что внутри тела самого компонента ничего не вызывает функцию установки состояния автоматически. Цикл начинается тогда, когда сеттер срабатывает во время фазы рендеринга без внешнего события.

Вызов SetState напрямую в теле компонента

Самый прямой способ создать бесконечный цикл — вызвать функцию установки состояния прямо внутри тела компонента. Поскольку тело компонента выполняется при каждом рендере, сеттер срабатывает при каждом рендере. Это новое обновление состояния вызывает еще один рендер. Цикл вращается бесконечно.

Вот как выглядит эта ошибка:

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

  setCount(count + 1);

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

Каждый раз, когда рендерится Counter, он увеличивает count. React рендерит компонент снова, чтобы показать новое число, снова видит setCount(count + 1) и увеличивает значение еще раз. Решение простое: никогда не вызывайте функцию установки состояния на верхнем уровне вашего компонента во время рендеринга. Обновления состояния должны реагировать на события пользователя или побочные эффекты, а не на сам акт отрисовки экрана. Вместо этого перенесите это обновление в обработчик события:

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

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

Единственное исключение — когда вы вычисляете новое состояние на основе props, но даже в этом случае следует использовать другой паттерн, например, вычислять значение напрямую или намеренно использовать useEffect.

Передача вызова функции вместо ссылки

Еще одна частая причина — незаметная опечатка в JSX. При привязке обработчика события вам нужно передать саму функцию. Если вы случайно вызываете функцию прямо в JSX, она запускается немедленно во время цикла рендеринга.

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

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

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

Записывая handleToggle() со скобками, вы не передаете React функцию, которую он сможет вызвать позже при клике пользователя. Вы вызываете её прямо сейчас, пока React строит виртуальный DOM. Поскольку handleToggle обновляет состояние, компонент делает ререндер. Во время этого ререндера React снова видит handleToggle() и вызывает её снова. Цикл никогда не закончится. Правильный вариант — без скобок:

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

Если вам нужно передать аргументы, оберните вызов в анонимную функцию:

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

Эта тонкость часто сбивает с толку даже опытных разработчиков при рефакторинге. Следите за этими скобками.

Ловушка зависимостей useEffect

Эффекты — это правильное место для побочных эффектов, таких как получение данных, синхронизация с браузерными API или ручное манипулирование DOM. Но useEffect запускается после того, как React зафиксировал рендер на экране. Если ваш эффект обновляет состояние, React выполнит ререндер. Обычно это нормально. Но это превращается в цикл, если эффект запускается после каждого рендера и всегда обновляет одно и то же состояние.

Рассмотрим этот ошибочный паттерн:

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

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

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

Так как массив зависимостей отсутствует, этот эффект запускается после каждого рендеринга. Он устанавливает user, что вызывает новый рендеринг. После этого рендеринга эффект запускается снова и снова устанавливает user. React обнаруживает бесконечную спираль и выдает ошибку.

Решение заключается в том, чтобы сообщить React, когда именно должен запускаться эффект, передав правильный массив зависимостей. Если эффект должен запуститься только один раз при монтировании, передайте пустой массив:

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

Если эффект зависит от пропса или части состояния, включите в массив только эту переменную. Однако будьте осторожны: включение переменной, которая меняется при каждом рендеринге, просто создаст тот же цикл, но «через другую дверь». Например, если вы включите объектный литерал в зависимости, и этот объект будет пересоздаваться при каждом рендеринге родителя, эффект будет срабатывать бесконечно. В таких случаях может потребоваться вынести создание объекта за пределы компонента или мемоизировать его.

Практические шаги по отладке

Когда вы сталкиваетесь с этой ошибкой, стек вызовов может выглядеть перегруженным, так как React уже повторил цикл десятки раз. Начните с чтения верхней части стека, чтобы найти компонент, который упоминается постоянно. Затем поищите функции установки состояния в этих трех местах:

  1. Основное тело компонента, вне любого обработчика или хука.
  2. Атрибуты событий JSX, где вы могли написать handler() вместо handler.
  3. Хуки useEffect, в которых отсутствует массив зависимостей или которые зависят от нестабильных ссылок.

Временно закомментируйте каждый сеттер состояния, пока ошибка не исчезнет. Это точно укажет на виновника. Если сеттер находится внутри эффекта, спросите себя, нужно ли вам там вообще состояние. Иногда разработчики устанавливают локальное состояние из пропсов внутри эффекта, хотя могли бы просто использовать пропс напрямую в JSX.

Главный вывод

Ошибка «Maximum update depth exceeded» — это не загадочный баг React. Это защитная сеть. Она означает, что ваш компонент пытается перерендерить сам себя вместо того, чтобы ждать внешнего сигнала. Избавьтесь от привычки относиться к рендерингу как к событию, которое должно порождать новое состояние. Относитесь к рендерингу как к чистому следствию состояния, а не как к его причине. Держите обновления состояния внутри обработчиков событий, колбэков или эффектов с тщательно подобранными зависимостями, и вы больше никогда не увидите эту ошибку.