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 сопоставляет их с состоянием по порядку вызова.»

Проверьте себя
1. В одном обработчике вызвали setCount(count + 1) три раза подряд. Начальное значение — 0. Каким станет count?
A3, потому что три раза увеличили на единицу
B1, потому что во всех трёх вызовах count — это захваченное значение 0
C0, потому что обновления взаимно отменяются
DЗависит от версии React Native
2. Что делает ленивая инициализация useState(() => heavyInit())?
AОткладывает вычисление до первого чтения состояния
BВызывает heavyInit только при первом рендере, а не на каждом
CДелает вычисление асинхронным, чтобы не блокировать JS-поток
DКэширует результат между запусками приложения
3. Почему нельзя вызывать хук внутри if?
AЭто запрещено линтером, но технически работает корректно
BReact сопоставляет хуки с состоянием по порядку вызова, и пропуск хука сдвигает всю нумерацию
CУсловные хуки ломают сборку Hermes
DВнутри if теряется доступ к props компонента