Как исправить ошибку «Maximum update depth exceeded» в React
Исправьте ошибку React maximum update depth exceeded с помощью простых правок для циклов рендера, зависимостей эффектов, обработчиков и throttling.
«Maximum update depth exceeded» означает, что ваш компонент застрял в бесконечном цикле рендеринга: обновление состояния вызывает повторный рендер, который снова вызывает то же самое обновление состояния, и React прерывает работу после превышения 50 вложенных обновлений (его NESTED_UPDATE_LIMIT), чтобы браузер не завис.
Если вы когда-нибудь наблюдали, как вкладка намертво зависает, а в консоли громоздится одна и та же красная строка, вам знакомо ощущение бесполезности этой стены текста на первый взгляд. Хорошая новость в том, что исправление почти всегда сводится к изменению одной строки — как только вы определите, на какой именно паттерн наступили.
В этом руководстве разобран полный набор причин, включая случай с высокочастотными обработчиками, с готовыми к копированию примерами «до/после» для каждого, таблицей поиска по симптомам для быстрой навигации и инструментами, которые помогут найти виновный сеттер меньше чем за минуту. Корневая причина одинакова и для функциональных, и для классовых компонентов: цикл обновлений, который никогда не приходит к завершению.
Ключевые выводы
- Ошибка — это бесконечный цикл рендеринга; React останавливает его после того, как счётчик вложенных обновлений превышает 50 (
NESTED_UPDATE_LIMITв исходниках reconciler). onClick={handleClick()}вызывает функцию во время рендера и планирует обновление состояния при каждом рендере. ПередавайтеonClick={handleClick}, а когда нужны аргументы —onClick={() => handleClick(id)}.- Самое универсальное исправление — функциональное обновление (
setCount(prev => prev + 1)), которое позволяет убрать это состояние из массива зависимостей эффекта и разрывает цикл «чтение — затем запись». useCallbackстабилизирует идентичность функции, а не частоту её выполнения, поэтому он не устранит циклы, вызванные высокочастотными обработчиками вродеonScrollилиonDragMoveиз dnd-kit; вместо этого используйте throttle или debounce для обновления состояния.- Ошибка
#185в классовых компонентах выбрасывается и в dev, и в production, тогда как вариант сuseEffect— предупреждение только для dev-режима. В production такой цикл работает без исключений и без каких-либо сигналов в консоли.
Таблица поиска: симптом → причина → исправление
Найдите симптом, соответствующий тому, что вы наблюдаете, а затем переходите к нужному исправлению.
| Симптом | Причина | Исправление |
|---|---|---|
| Цикл без клика, начинается при монтировании | onClick={fn()} вызывает сеттер во время рендера | Передавайте onClick={fn} |
setState на верхнем уровне компонента | Обновление состояния в пути рендера | Перенесите в обработчик или эффект |
| Эффект срабатывает при каждом рендере | Отсутствующие/неверные зависимости или инлайновый объект/массив в зависимостях | Исправьте зависимости + useMemo/useCallback |
| Эффект читает и записывает одно и то же состояние | Состояние находится в собственных зависимостях | Функциональный updater; уберите зависимость |
| Цикл только при перетаскивании/прокрутке/изменении размера | setState на каждое высокочастотное событие | Throttle/debounce для обновления |
| Родитель и потомок перезаписывают друг друга | Двунаправленная синхронизация состояния | Сделайте поток однонаправленным |
Discover how at OpenReplay.com.
Исправления 1 и 2: ссылки на обработчики и setState в пути рендера
Две самые быстрые победы связаны с тем, как вы подключаете обработчики и где вызываете сеттер. onClick={handleClick()} вызывает функцию во время рендера и планирует обновление состояния при каждом рендере; почти всегда вам нужен onClick={handleClick}, а когда требуется передать аргументы — onClick={() => handleClick(id)}.
// BAD: acceptTerms runs during render, every render
<input type="checkbox" onChange={acceptTerms()} />
// GOOD: pass the reference; wrap in an arrow to pass args
<input type="checkbox" onChange={acceptTerms} />
<button onClick={() => selectItem(item.id)}>Select</button>
Тот же цикл возникает, когда вы вызываете сеттер напрямую в теле компонента. Обновления состояния должны быть в обработчике события или в эффекте, но никогда — в пути рендера.
// BAD: runs on every render → loop
function Counter() {
const [count, setCount] = useState(0);
setCount(count + 1);
return <div>{count}</div>;
}
// GOOD: update in response to an event
const increment = () => setCount(c => c + 1);
Исправления 3, 4 и 5: циклы зависимостей эффектов
Большинство циклов с эффектами возникает из-за зависимостей, меняющих идентичность, или из-за эффекта, который записывает то же состояние, которое читает. Литерал объекта или массива, записанный прямо в массиве зависимостей, получает новую идентичность при каждом рендере, поэтому эффект перезапускается каждый рендер; оберните его в useMemo (объекты/массивы) или useCallback (функции), чтобы ссылка оставалась стабильной.
// BAD: options is a new object each render → effect re-runs forever
const options = { limit: 10, sort: 'date' };
useEffect(() => { search(query, options).then(setResults); }, [query, options]);
// GOOD: memoize so the reference is stable
const options = useMemo(() => ({ limit: 10, sort: 'date' }), []);
Самое универсальное исправление — функциональное обновление. Когда эффект одновременно читает и записывает одно и то же состояние, setCount(prev => prev + 1) берёт предыдущее значение из аргумента updater’а, а не из замыкания, что позволяет убрать это состояние из массива зависимостей и разрывает цикл.
// BAD: count is read and written, and it's in deps
useEffect(() => { setCount(count + 1); }, [count]);
// GOOD: functional updater removes the dependency
useEffect(() => { setCount(prev => prev + 1); }, []);
Если эффект зависит от функции, либо оберните её в useCallback с правильными зависимостями, либо перенесите внутрь эффекта: функция, объявленная внутри эффекта, создаётся один раз за запуск и не должна быть зависимостью.
Исправление 6: throttle для высокочастотных обработчиков
useCallback стабилизирует идентичность функции между рендерами, но не меняет, как часто эта функция выполняется, поэтому он не устранит бесконечный цикл, вызванный высокочастотным обработчиком вроде onScroll, onMouseMove, onResize или onDragMove из dnd-kit. Такие события срабатывают десятки раз в секунду, и каждый setState планирует очередной рендер. Вместо этого используйте throttle или debounce для обновления состояния.
// BAD: fires dozens of times/sec while dragging
const handleDragMove = (event) => setDragPreview(compute(event));
// GOOD: cap the update rate; useCallback alone won't help
import { throttle } from 'lodash';
const handleDragMove = throttle((event) => setDragPreview(compute(event)), 100);
throttle из lodash ограничивает, сколько раз обёрнутая функция может сработать внутри заданного окна; нативный requestAnimationFrame или debounce тоже подойдут. Суть в контроле частоты, а не в стабильности ссылки.
Исправление 7: циклы синхронизации «родитель — потомок»
Двунаправленное распространение состояния (эффект в потомке вызывает сеттер родителя, тот перерисовывает потомка, что снова запускает эффект) — ещё один классический цикл. Поднимите состояние к одному владельцу и сделайте поток данных однонаправленным либо преобразуйте значение в родителе, вместо того чтобы синхронизировать его обратно через эффект.
Как найти причину быстро (и случай классовых компонентов)
Действуйте сверху вниз: прочитайте stack trace до именованного сеттера в начале цикла, затем откройте Profiler в React DevTools, чтобы найти компонент, который перерисовывается без остановки. Подключите why-did-you-render (протестирован с React 19, только для dev-режима, не тестировался с React Compiler), чтобы увидеть, какой проп или элемент состояния сменил идентичность. Отлавливайте такие ошибки уже на этапе линтинга: начиная с eslint-plugin-react-hooks 7.x плагин поставляется со специальными правилами set-state-in-render и set-state-in-effect, которые ловят два самых частых триггера ещё до запуска приложения. Оба входят в набор по умолчанию recommended, поэтому достаточно обновить плагин, чтобы их включить; recommended-latest лишь добавляет сверху экспериментальные правила компилятора. set-state-in-render срабатывает, когда компонент устанавливает состояние во время рендера без каких-либо условий, ограничивающих этот вызов, — именно такая конструкция и раскручивается в цикл.
Корневая причина ошибки одинакова и в функциональных, и в классовых компонентах; в классовых компонентах она обычно означает вызов setState во время рендера или безусловный вызов внутри componentDidUpdate. Обращайте внимание и на окружение: ошибка #185 в классовых компонентах выбрасывается и в development, и в production, тогда как вариант с useEffect — это только предупреждение в development. В исходниках reconciler проверка вложенных пассивных обновлений находится внутри dev-only условия и выводит сообщение в консоль вместо выбрасывания исключения, поэтому в production-сборке этот цикл эффектов работает без исключения, без error boundary и без сигнала в консоли — и проявляется лишь в виде зависшей вкладки.
Именно этот разрыв с production делает отладку цикла дорогой. Консоль показывает ошибку, но не то взаимодействие, которое её вызвало, а для циклов с эффектами она может вообще ничего не показать. Инструменты session replay, такие как OpenReplay, фиксируют ошибку консоли (если она есть) вместе с последовательностью действий пользователя, которые ей предшествовали, — так голый stack trace (или беззвучное зависание) превращается в воспроизводимый пошаговый сценарий, который можно прогнать против описанных выше исправлений.
Как только вы сопоставите свой симптом со строкой в таблице, исправление обычно займёт одну строку: ссылка вместо вызова, useMemo вокруг объекта или функциональный updater, позволяющий избавиться от зависимости. Настройте exhaustive-deps вместе с более новыми правилами set-state-in-*, чтобы следующий цикл ломал ваш этап линтинга, а не браузеры ваших пользователей.
Частые вопросы
В чём разница между вариантом этой ошибки с useEffect и ошибкой React #185?
Это две разные строки с разным поведением во время выполнения. Ошибка #185 — формулировка для классовых компонентов, упоминающая setState внутри componentWillUpdate или componentDidUpdate, и она выбрасывается и в development, и в production. Вариант с useEffect — отдельное предупреждение из исходников reconciler, доступное только в dev-режиме и закрытое development-only условием, поэтому в production цикл эффектов работает без исключения, без error boundary и вообще без каких-либо сигналов в консоли.
Почему добавление useCallback не останавливает бесконечный цикл при перетаскивании или прокрутке?
Потому что useCallback стабилизирует идентичность функции между рендерами, но не меняет частоту её выполнения. Высокочастотный обработчик вроде onScroll, onMouseMove или onDragMove из dnd-kit срабатывает десятки раз в секунду, и каждый setState планирует очередной рендер независимо от того, мемоизирована ли ссылка на функцию. Решение — контроль частоты: примените throttle или debounce к самому обновлению состояния, используя throttle из lodash, debounce или requestAnimationFrame.
Всегда ли функциональная форма updater'а позволяет убрать состояние из массива зависимостей эффекта?
Только если состояние нужно эффекту исключительно для вычисления следующего значения. Запись setCount(prev => prev + 1) берёт предыдущее значение из аргумента updater'а, а не из замыкания, поэтому count можно убрать из массива зависимостей, и цикл «чтение — затем запись» разрывается. Если же эффект читает это состояние и для другой логики — например, для ветвления или передачи в другую функцию, — оно по-прежнему требуется в зависимостях, и цикл придётся разрывать иначе.
Почему ошибка появляется в development, а production-сборка просто беззвучно зависает?
Потому что проверка вложенных пассивных обновлений для варианта с useEffect обёрнута в development-only условие в React reconciler, поэтому она выводит предупреждение в консоль только во время разработки. В production-сборке это условие не выполняется, а значит, тот же цикл эффектов работает без выброшенной ошибки и без сообщения в консоли, проявляясь лишь как зависшая вкладка или бесконтрольные рендеры. Ошибка #185 в классовых компонентах устроена иначе и выбрасывается в обеих средах.
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.
Star on GitHub12k