useState: батчинг, устаревшие замыкания и правила хуков
Самый частый практический вопрос по React: «почему состояние не изменилось сразу после setState?»
Состояние в функциональном компоненте — это снимок на момент конкретного рендера. Вызов сеттера не меняет переменную, а планирует новый рендер с новым снимком.
Вопрос
«Что выведет console.log сразу после setCount? Что будет, если вызвать setCount три раза подряд? Когда нужен вариант setCount(c => c + 1)?»
Что на самом деле проверяет интервьюер
Понимаете ли вы, что рендер — это функция, а состояние — её аргумент. Кандидаты, которые мыслят состоянием как изменяемой переменной объекта (привычка из классовых компонентов или из Vue), почти всегда попадаются на этом вопросе и потом пишут баги с «устаревшими» данными в таймерах и обработчиках.
Развёрнутый ответ
Состояние — это снимок рендера
import { useState } from 'react';
import { View, Text, Button } from 'react-native';
export default function Counter() {
const [count, setCount] = useState(0);
const onPress = () => {
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
console.log(count); // 0 — переменная этого рендера не изменилась
};
return (
<View>
<Text>{count}</Text>
<Button title="+3?" onPress={onPress} />
</View>
);
}
После нажатия count станет 1, а не 3. Внутри обработчика count — константа, захваченная замыканием при рендере. Все три вызова вычисляют 0 + 1.
Тот же эффект легко воспроизвести на чистом JS — замыкание держит значение, а не «живую» ссылку:
function makeState() {
let value = 0;
return {
read: () => value,
set: (next) => { value = next; },
};
}
const state = makeState();
const snapshot = state.read(); // так рендер «захватывает» состояние
state.set(snapshot + 1);
state.set(snapshot + 1);
state.set(snapshot + 1);
console.log('снимок рендера:', snapshot);
console.log('реальное значение:', state.read());
Вывод:
снимок рендера: 0
реальное значение: 1
Функциональное обновление
Если новое значение зависит от предыдущего, передавайте функцию: React вызовет её с актуальным значением из очереди обновлений.
const onPress = () => {
setCount((c) => c + 1);
setCount((c) => c + 1);
setCount((c) => c + 1); // теперь будет 3
};
Это же лекарство от «устаревшего замыкания» в таймерах, подписках и обработчиках, которые создаются один раз, а живут долго.
Батчинг
Несколько вызовов сеттера внутри одного обработчика приводят к одному рендеру, а не к трём — React группирует обновления. В React 18 и новее (а значит, и в актуальных React Native) батчинг работает и в промисах, и в таймерах, и в нативных колбэках, а не только в обработчиках событий.
Ленивая инициализация
// плохо: parse выполняется на КАЖДОМ рендере, результат нужен только на первом
const [settings, setSettings] = useState(JSON.parse(rawSettings));
// хорошо: функция вызывается один раз, при монтировании
const [settings, setSettings] = useState(() => JSON.parse(rawSettings));
Правила хуков и почему они существуют
Хуки нельзя вызывать в условиях, циклах, вложенных функциях и после раннего return. Причина техническая: у хуков нет имён, React хранит их в списке и сопоставляет с состоянием по порядковому номеру вызова. Пропустили хук на одном рендере — и значения съехали.
// НЕПРАВИЛЬНО: на разных рендерах разное количество хуков
if (props.isPremium) {
const [plan, setPlan] = useState('pro');
}
// ПРАВИЛЬНО: хук вызывается всегда, условие внутри значения
const [plan, setPlan] = useState(props.isPremium ? 'pro' : 'free');
Объекты в состоянии
Сеттер заменяет состояние, а не сливает его, как this.setState в классах. Мутировать объект напрямую тоже нельзя: React сравнивает ссылки и не увидит изменений.
// не сработает: ссылка та же
form.email = value;
setForm(form);
// правильно: новая ссылка
setForm((prev) => ({ ...prev, email: value }));
Типичные ошибки кандидатов
- Говорят «setState асинхронный, поэтому лог показывает старое значение». Формулировка неточная: переменная и не могла измениться — она принадлежит завершившемуся рендеру.
- Используют функциональное обновление везде подряд «на всякий случай» и не могут объяснить, когда оно действительно нужно.
- Мутируют объект или массив в состоянии, а потом удивляются, что экран не перерисовался.
- Не знают про ленивую инициализацию и парсят JSON на каждом рендере — на мобильном устройстве это заметно.
Как ответить кратко
«Состояние — снимок конкретного рендера, а не изменяемая переменная. Сеттер не меняет её, а планирует новый рендер, поэтому лог сразу после setCount покажет старое значение, а три вызова setCount(count + 1) дадут +1, потому что все три считают от одного и того же снимка. Если новое значение зависит от предыдущего, нужно setCount(c => c + 1). Обновления батчатся в один рендер. Хуки вызываются только на верхнем уровне, потому что React сопоставляет их с состоянием по порядку вызова.»