В этом React‑фрагменте счётчик ведёт себя не так, как ожидают пользователи: function Counter() { const [count, setCount] = useState(0); function handleClick() { setTimeout(() => { setCount(count + 1); }, 1000); } return {count}; } — объясните причину (замыкания/stale state), варианты исправления и последствия для производительности и тестируемости
Коротко — причина в «устаревшем» значении из замыкания. Разъяснение, варианты правки и последствия.
Почему так происходит
Каждый рендер создаёт свои локальные переменные и функции. Когда вы кликаете, handleClick создаёт таймаут, а стрелочная функция в setTimeout «захватывает» текущее значение count из того рендера.Если потом, прежде чем таймаут сработает, выполнится ещё несколько кликов (то есть создаются новые таймауты из других рендеров), все эти колбэки могут использовать одно и то же старое значение count. В итоге три клика могут увеличить значение только на 1 вместо 3.Это классический stale closure / stale state — callback видит старый стейт.
Простой пример ошибки
Начальное count = 0.Три клика за 500ms: созданы три таймаута, каждый захватил count == 0.Через 1s каждый выполнит setCount(0 + 1) — итог count == 1, а ожидалось 3.
Как исправить 1) Использовать функциональную форму setState (рекомендуемо)
setCount(prev => prev + 1) использует актуальное значение состояния в момент выполнения, независимо от того, какое значение было захвачено в замыкании. Пример: function handleClick() { setTimeout(() => { setCount(prev => prev + 1); }, 1000); }
2) Хранить «последнее» значение в ref и читать его в таймауте
useRef содержит актуальное значение без триггера рендера. Полезно, если нужно прочитать состояние в асинхронном колбэке, но не хотим пересоздавать обработчики. Пример: const latest = useRef(count); useEffect(() => { latest.current = count; }, [count]); setTimeout(() => { setCount(latest.current + 1); }, 1000);Минус: вы читаете ref и обновляете состояние вручную, но легче допустить ошибку; чаще достаточно функционального апдейта.
3) Отменять таймауты при анмаунте / очищать их
Если компонент может размонтироваться до срабатывания таймаута, надо clearTimeout в useEffect cleanup, чтобы избежать вызова setState на размонтированном компоненте.
4) Абстрагировать таймеры / инжектировать зависимость в тестах
Для тестирования удобнее инкапсулировать таймеры в хук или прокинуть функцию setTimeout, чтобы мокать её в тестах.
Последствия для производительности
Функциональный апдейтер (setCount(prev => prev + 1)) — дешёв и предпочтителен; не создаёт лишних рендеров сам по себе.useRef не вызывает ререндер при записи, поэтому чтение latest.current быстрее, но вы всё равно вызываете setCount для изменения UI.Массовые таймауты/интервалы могут потребовать очистки, иначе утечка памяти/неожиданные обновления. Очистка — хорошая практика.Никакой значительной негативной производительности от использования функционального апдейтера нет; напротив, он предотвращает лишние исправления багов.
Последствия для тестируемости
Ста́лые замыкания приводят к трудноуловимым багам и неочевидным ожиданиям в тестах.Компоненты с явными таймерами труднее тестировать: в юнит-тестах придётся использовать jest.useFakeTimers() и advanceTimersByTime(), либо мокать таймеры.Использование функционального апдейтера упрощает тесты, т.к. поведение предсказуемо: каждый вызов инкремента действительно прибавляет 1.Абстрагирование таймеров (или передача setTimeout как зависимости) делает компонент более детерминированным и удобным для мока в тестах.
Рекомендация
В большинстве случаев просто заменить setCount(count + 1) на setCount(prev => prev + 1). Добавить очистку таймаута в useEffect при необходимости. Это решение простое, безопасное и хорошо тестируемое.
Если хотите, могу показать готовый компонент с очисткой таймаутов и тестовым кейсом для Jest.
Коротко — причина в «устаревшем» значении из замыкания. Разъяснение, варианты правки и последствия.
Почему так происходит
Каждый рендер создаёт свои локальные переменные и функции. Когда вы кликаете, handleClick создаёт таймаут, а стрелочная функция в setTimeout «захватывает» текущее значение count из того рендера.Если потом, прежде чем таймаут сработает, выполнится ещё несколько кликов (то есть создаются новые таймауты из других рендеров), все эти колбэки могут использовать одно и то же старое значение count. В итоге три клика могут увеличить значение только на 1 вместо 3.Это классический stale closure / stale state — callback видит старый стейт.Простой пример ошибки
Начальное count = 0.Три клика за 500ms: созданы три таймаута, каждый захватил count == 0.Через 1s каждый выполнит setCount(0 + 1) — итог count == 1, а ожидалось 3.Как исправить
setCount(prev => prev + 1) использует актуальное значение состояния в момент выполнения, независимо от того, какое значение было захвачено в замыкании.1) Использовать функциональную форму setState (рекомендуемо)
Пример:
function handleClick() {
setTimeout(() => {
setCount(prev => prev + 1);
}, 1000);
}
2) Хранить «последнее» значение в ref и читать его в таймауте
useRef содержит актуальное значение без триггера рендера. Полезно, если нужно прочитать состояние в асинхронном колбэке, но не хотим пересоздавать обработчики.Пример:
const latest = useRef(count);
useEffect(() => { latest.current = count; }, [count]);
setTimeout(() => {
setCount(latest.current + 1);
}, 1000);Минус: вы читаете ref и обновляете состояние вручную, но легче допустить ошибку; чаще достаточно функционального апдейта.
3) Отменять таймауты при анмаунте / очищать их
Если компонент может размонтироваться до срабатывания таймаута, надо clearTimeout в useEffect cleanup, чтобы избежать вызова setState на размонтированном компоненте.4) Абстрагировать таймеры / инжектировать зависимость в тестах
Для тестирования удобнее инкапсулировать таймеры в хук или прокинуть функцию setTimeout, чтобы мокать её в тестах.Последствия для производительности
Функциональный апдейтер (setCount(prev => prev + 1)) — дешёв и предпочтителен; не создаёт лишних рендеров сам по себе.useRef не вызывает ререндер при записи, поэтому чтение latest.current быстрее, но вы всё равно вызываете setCount для изменения UI.Массовые таймауты/интервалы могут потребовать очистки, иначе утечка памяти/неожиданные обновления. Очистка — хорошая практика.Никакой значительной негативной производительности от использования функционального апдейтера нет; напротив, он предотвращает лишние исправления багов.Последствия для тестируемости
Ста́лые замыкания приводят к трудноуловимым багам и неочевидным ожиданиям в тестах.Компоненты с явными таймерами труднее тестировать: в юнит-тестах придётся использовать jest.useFakeTimers() и advanceTimersByTime(), либо мокать таймеры.Использование функционального апдейтера упрощает тесты, т.к. поведение предсказуемо: каждый вызов инкремента действительно прибавляет 1.Абстрагирование таймеров (или передача setTimeout как зависимости) делает компонент более детерминированным и удобным для мока в тестах.Рекомендация
В большинстве случаев просто заменить setCount(count + 1) на setCount(prev => prev + 1). Добавить очистку таймаута в useEffect при необходимости. Это решение простое, безопасное и хорошо тестируемое.Если хотите, могу показать готовый компонент с очисткой таймаутов и тестовым кейсом для Jest.