useState продвинутое использование в React

16 марта 2026
Автор

Олег Марков

Введение

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

В этой статье вы узнаете:

  • Как использовать ленивую инициализацию для оптимизации первого рендера
  • Почему функциональные обновления критически важны в асинхронном коде
  • Как правильно работать с объектами и массивами в состоянии
  • Что такое батчинг обновлений и как он изменился в React 18
  • Как управлять состоянием сложных форм
  • Как моделировать машины состояний через useState
  • Зачем и как использовать key для сброса состояния компонента
  • Когда стоит переходить от useState к useReducer
  • Как правильно типизировать useState в TypeScript
  • Распространённые ошибки и антипаттерны
  • Советы по оптимизации производительности

Если вы хотите систематически изучить React и освоить все инструменты управления состоянием — приходите на курс Основы React, React Router и Redux Toolkit. На курсе 177 уроков и 17 упражнений, AI-тренажёры для безлимитной практики с кодом и задачами 24/7, решение задач с живым ревью наставника, еженедельные встречи с менторами.

Ленивая инициализация (Lazy Initialization)

Проблема: дорогостоящее начальное значение

По умолчанию React вычисляет аргумент useState при каждом рендере компонента. Если это выражение требует сложных вычислений или обращения к медленному API (например, localStorage), производительность страдает на каждом рендере, хотя результат используется только один раз — при монтировании.

// Плохо: expensiveComputation() вызывается при КАЖДОМ рендере
const [state, setState] = useState(expensiveComputation());

// Хорошо: expensiveComputation() вызывается только при первом рендере
const [state, setState] = useState(() => expensiveComputation());

Синтаксис ленивой инициализации

Вместо значения в useState передаётся функция-инициализатор. React вызовет её только один раз — при первом рендере компонента — и использует возвращённое значение как начальное состояние.

// Форма: функция без аргументов, возвращающая начальное значение
const [value, setValue] = useState(() => {
  // Эта функция выполняется только при монтировании
  return computeInitialValue();
});

Практические примеры

Чтение из localStorage

function ThemeToggle() {
  // Без ленивой инициализации: localStorage.getItem вызывается при каждом рендере
  // const [theme, setTheme] = useState(localStorage.getItem('theme') || 'light');

  // С ленивой инициализацией: читаем localStorage только один раз
  const [theme, setTheme] = useState(() => {
    try {
      return localStorage.getItem('theme') || 'light';
    } catch {
      // localStorage может быть недоступен (приватный режим браузера, SSR)
      return 'light';
    }
  });

  const toggle = () => {
    setTheme(prev => {
      const next = prev === 'light' ? 'dark' : 'light';
      localStorage.setItem('theme', next);
      return next;
    });
  };

  return (
    <button onClick={toggle}>
      Текущая тема: {theme}
    </button>
  );
}

Восстановление состояния формы

interface FormState {
  name: string;
  email: string;
  message: string;
}

function ContactForm() {
  const [form, setForm] = useState<FormState>(() => {
    const saved = sessionStorage.getItem('contactForm');
    if (saved) {
      try {
        return JSON.parse(saved);
      } catch {
        // Игнорируем повреждённые данные
      }
    }
    return { name: '', email: '', message: '' };
  });

  const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
    const { name, value } = e.target;
    setForm(prev => {
      const next = { ...prev, [name]: value };
      // Сохраняем прогресс заполнения
      sessionStorage.setItem('contactForm', JSON.stringify(next));
      return next;
    });
  };

  return (
    <form>
      <input name="name" value={form.name} onChange={handleChange} placeholder="Имя" />
      <input name="email" value={form.email} onChange={handleChange} placeholder="Email" />
      <textarea name="message" value={form.message} onChange={handleChange} placeholder="Сообщение" />
    </form>
  );
}

Инициализация из props

interface TableProps {
  initialData: Row[];
  defaultSort: 'asc' | 'desc';
}

function DataTable({ initialData, defaultSort }: TableProps) {
  // Ленивая инициализация позволяет выполнить первичную обработку данных
  const [rows, setRows] = useState<Row[]>(() => {
    // Сортируем данные только при монтировании
    return [...initialData].sort((a, b) =>
      defaultSort === 'asc'
        ? a.id - b.id
        : b.id - a.id
    );
  });

  // rows уже отсортированы, компонент готов к работе
  return <table>{/* ... */}</table>;
}

Когда использовать ленивую инициализацию

Ситуация Использовать?
Чтение из localStorage / sessionStorage Да
Парсинг JSON из внешнего источника Да
Сложные вычисления начального состояния Да
Создание больших структур данных Да
Простые примитивы (0, '', false) Нет, не нужно
Константные объекты/массивы Нет, React это оптимизирует

Функциональные обновления состояния

Проблема устаревшего замыкания (stale closure)

Когда обработчики событий или эффекты захватывают значение состояния через замыкание, они могут работать с устаревшим значением — тем, которое было актуально в момент создания функции, а не в момент её вызова.

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

  const handleTripleIncrement = () => {
    // count захвачен замыканием и равен 0 во всех трёх вызовах
    setCount(count + 1); // планируется: 0 + 1 = 1
    setCount(count + 1); // планируется: 0 + 1 = 1 (не 2!)
    setCount(count + 1); // планируется: 0 + 1 = 1 (не 3!)
    // Итог: count будет 1, а не 3
  };

  return (
    <div>
      <p>Счётчик: {count}</p>
      <button onClick={handleTripleIncrement}>+3</button>
    </div>
  );
}

Решение: функциональная форма setState

Когда в setState передаётся функция, React передаёт ей гарантированно актуальное значение предыдущего состояния — то, которое было применено после всех предыдущих обновлений в очереди.

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

  const handleTripleIncrement = () => {
    setCount(prev => prev + 1); // prev = 0, возвращает 1
    setCount(prev => prev + 1); // prev = 1, возвращает 2
    setCount(prev => prev + 1); // prev = 2, возвращает 3
    // Итог: count будет 3
  };

  return (
    <div>
      <p>Счётчик: {count}</p>
      <button onClick={handleTripleIncrement}>+3</button>
    </div>
  );
}

Функциональные обновления в асинхронном коде

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

function AsyncCounter() {
  const [count, setCount] = useState(0);

  const incrementAfterDelay = async () => {
    // count = 5 в момент клика
    await new Promise(resolve => setTimeout(resolve, 2000));
    // Через 2 секунды пользователь мог нажать кнопку ещё раз
    // count всё ещё = 5 (из замыкания), а реальное значение может быть 7

    // Плохо: используем устаревшее значение из замыкания
    setCount(count + 1); // 5 + 1 = 6, а должно было быть 8

    // Хорошо: используем актуальное значение
    setCount(prev => prev + 1); // всегда корректно
  };

  return (
    <div>
      <p>Счётчик: {count}</p>
      <button onClick={() => setCount(c => c + 1)}>+1</button>
      <button onClick={incrementAfterDelay}>+1 через 2 секунды</button>
    </div>
  );
}

Функциональные обновления в useCallback и useEffect

function SearchResults() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState<string[]>([]);
  const [page, setPage] = useState(1);

  // Функциональное обновление позволяет не добавлять page в зависимости
  const loadNextPage = useCallback(async () => {
    // Используем функциональную форму, чтобы не добавлять page в deps
    setPage(prev => {
      const nextPage = prev + 1;
      // Загружаем данные для следующей страницы
      fetchResults(query, nextPage).then(data => {
        setResults(prevResults => [...prevResults, ...data]);
      });
      return nextPage;
    });
  }, [query]); // page не нужен в зависимостях

  return (
    <div>
      <input value={query} onChange={e => setQuery(e.target.value)} />
      {results.map(r => <div key={r}>{r}</div>)}
      <button onClick={loadNextPage}>Загрузить ещё</button>
    </div>
  );
}

Правило: когда использовать функциональную форму

Используйте функциональную форму setState(prev => newValue) всегда, когда новое значение зависит от предыдущего. Это защищает от race conditions и устаревших замыканий.

// Всегда используйте функциональную форму для:
setCount(prev => prev + 1);          // инкремент
setItems(prev => [...prev, newItem]); // добавление в массив
setFlags(prev => ({ ...prev, isOpen: !prev.isOpen })); // toggle
setHistory(prev => [...prev, current]); // накопление истории

Работа с объектами и массивами в состоянии

Принцип иммутабельности

React использует ссылочное равенство для определения того, изменилось ли состояние. Если вы мутируете объект или массив напрямую, React не увидит изменений и не выполнит повторный рендер.

const [user, setUser] = useState({ name: 'Иван', age: 25 });

// Плохо: мутация — React не увидит изменения
user.name = 'Пётр'; // Изменяем тот же объект
setUser(user);       // React сравнивает ссылки: они одинаковые, рендер не запускается

// Хорошо: создаём новый объект
setUser({ ...user, name: 'Пётр' }); // Новая ссылка — React видит изменение

Работа с объектами

Поверхностное обновление через spread

interface Profile {
  name: string;
  email: string;
  bio: string;
  isPublic: boolean;
}

function ProfileEditor() {
  const [profile, setProfile] = useState<Profile>({
    name: 'Иван Петров',
    email: 'ivan@example.com',
    bio: 'Разработчик',
    isPublic: true,
  });

  // Обновление одного поля
  const updateName = (name: string) => {
    setProfile(prev => ({ ...prev, name }));
  };

  // Универсальный обработчик для текстовых полей
  const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
    const { name, value, type } = e.target;
    setProfile(prev => ({
      ...prev,
      [name]: type === 'checkbox' ? (e.target as HTMLInputElement).checked : value,
    }));
  };

  // Обновление булевого поля
  const togglePublic = () => {
    setProfile(prev => ({ ...prev, isPublic: !prev.isPublic }));
  };

  return (
    <form>
      <input name="name" value={profile.name} onChange={handleChange} />
      <input name="email" value={profile.email} onChange={handleChange} />
      <textarea name="bio" value={profile.bio} onChange={handleChange} />
      <label>
        <input
          type="checkbox"
          name="isPublic"
          checked={profile.isPublic}
          onChange={handleChange}
        />
        Публичный профиль
      </label>
    </form>
  );
}

Обновление вложенных объектов

interface Address {
  street: string;
  city: string;
  country: string;
  zipCode: string;
}

interface User {
  name: string;
  email: string;
  address: Address;
  settings: {
    notifications: boolean;
    theme: 'light' | 'dark';
  };
}

function UserEditor() {
  const [user, setUser] = useState<User>({
    name: 'Анна',
    email: 'anna@example.com',
    address: { street: 'ул. Пушкина', city: 'Москва', country: 'Россия', zipCode: '101000' },
    settings: { notifications: true, theme: 'light' },
  });

  // Обновление вложенного поля адреса
  const updateCity = (city: string) => {
    setUser(prev => ({
      ...prev,
      address: { ...prev.address, city },
    }));
  };

  // Обновление вложенного поля настроек
  const toggleNotifications = () => {
    setUser(prev => ({
      ...prev,
      settings: {
        ...prev.settings,
        notifications: !prev.settings.notifications,
      },
    }));
  };

  // Универсальный обработчик для вложенных полей
  const updateNestedField = <K extends keyof User>(
    section: K,
    field: keyof User[K],
    value: unknown
  ) => {
    setUser(prev => ({
      ...prev,
      [section]: {
        ...(prev[section] as object),
        [field]: value,
      },
    }));
  };

  return (
    <div>
      <input
        value={user.address.city}
        onChange={e => updateCity(e.target.value)}
        placeholder="Город"
      />
      <button onClick={toggleNotifications}>
        Уведомления: {user.settings.notifications ? 'вкл' : 'выкл'}
      </button>
    </div>
  );
}

Работа с массивами

Все операции с массивами

interface Todo {
  id: number;
  text: string;
  done: boolean;
  priority: 'low' | 'medium' | 'high';
  createdAt: Date;
}

function TodoManager() {
  const [todos, setTodos] = useState<Todo[]>([]);
  const [nextId, setNextId] = useState(1);

  // Добавление элемента в конец
  const addTodo = (text: string, priority: Todo['priority'] = 'medium') => {
    setTodos(prev => [
      ...prev,
      { id: nextId, text, done: false, priority, createdAt: new Date() },
    ]);
    setNextId(prev => prev + 1);
  };

  // Добавление элемента в начало
  const prependTodo = (text: string) => {
    setTodos(prev => [
      { id: nextId, text, done: false, priority: 'high', createdAt: new Date() },
      ...prev,
    ]);
    setNextId(prev => prev + 1);
  };

  // Удаление элемента по id
  const removeTodo = (id: number) => {
    setTodos(prev => prev.filter(todo => todo.id !== id));
  };

  // Обновление элемента
  const toggleTodo = (id: number) => {
    setTodos(prev =>
      prev.map(todo =>
        todo.id === id ? { ...todo, done: !todo.done } : todo
      )
    );
  };

  // Обновление текста
  const updateTodoText = (id: number, text: string) => {
    setTodos(prev =>
      prev.map(todo =>
        todo.id === id ? { ...todo, text } : todo
      )
    );
  };

  // Замена элемента целиком
  const replaceTodo = (id: number, newTodo: Omit<Todo, 'id'>) => {
    setTodos(prev =>
      prev.map(todo =>
        todo.id === id ? { ...newTodo, id } : todo
      )
    );
  };

  // Вставка элемента на конкретную позицию
  const insertAt = (index: number, text: string) => {
    setTodos(prev => [
      ...prev.slice(0, index),
      { id: nextId, text, done: false, priority: 'medium', createdAt: new Date() },
      ...prev.slice(index),
    ]);
    setNextId(prev => prev + 1);
  };

  // Перемещение элемента
  const moveTodo = (fromIndex: number, toIndex: number) => {
    setTodos(prev => {
      const result = [...prev];
      const [moved] = result.splice(fromIndex, 1);
      result.splice(toIndex, 0, moved);
      return result;
    });
  };

  // Сортировка (создаём новый массив, не мутируем)
  const sortByPriority = () => {
    const order = { high: 0, medium: 1, low: 2 };
    setTodos(prev => [...prev].sort((a, b) => order[a.priority] - order[b.priority]));
  };

  const sortByDate = () => {
    setTodos(prev =>
      [...prev].sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
    );
  };

  // Пакетное обновление
  const markAllDone = () => {
    setTodos(prev => prev.map(todo => ({ ...todo, done: true })));
  };

  const clearCompleted = () => {
    setTodos(prev => prev.filter(todo => !todo.done));
  };

  return (
    <div>
      <button onClick={() => addTodo('Новая задача')}>Добавить</button>
      <button onClick={sortByPriority}>Сортировать по приоритету</button>
      <button onClick={markAllDone}>Отметить все выполненными</button>
      <button onClick={clearCompleted}>Удалить выполненные</button>
      <ul>
        {todos.map(todo => (
          <li key={todo.id}>
            <input
              type="checkbox"
              checked={todo.done}
              onChange={() => toggleTodo(todo.id)}
            />
            {todo.text}
            <button onClick={() => removeTodo(todo.id)}>Удалить</button>
          </li>
        ))}
      </ul>
    </div>
  );
}

Распространённые ошибки при работе с массивами

// Плохо: мутация массива напрямую
const addItem = (item: string) => {
  todos.push(item); // Мутация!
  setTodos(todos); // React не увидит изменения — та же ссылка
};

// Плохо: sort мутирует оригинальный массив
const sortItems = () => {
  setTodos(todos.sort()); // sort() мутирует массив и возвращает ту же ссылку!
};

// Хорошо: создаём копию перед сортировкой
const sortItems = () => {
  setTodos([...todos].sort()); // spread создаёт новый массив
  // или
  setTodos(prev => [...prev].sort());
};

// Плохо: splice мутирует массив
const removeItem = (index: number) => {
  todos.splice(index, 1); // Мутация!
  setTodos(todos);
};

// Хорошо: filter создаёт новый массив
const removeItem = (index: number) => {
  setTodos(prev => prev.filter((_, i) => i !== index));
};

Батчинг обновлений (State Batching)

Что такое батчинг

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

Батчинг в React 17 и ранее

В React 17 батчинг работал только внутри синтетических обработчиков событий React. В асинхронном коде каждый вызов setState вызывал отдельный рендер.

// React 17: внутри обработчика — один рендер
function handleClick() {
  setCount(c => c + 1); // Не рендерит сразу
  setFlag(f => !f);     // Не рендерит сразу
  // Один рендер здесь
}

// React 17: внутри setTimeout — два отдельных рендера!
setTimeout(() => {
  setCount(c => c + 1); // Рендер 1
  setFlag(f => !f);     // Рендер 2
}, 1000);

Автоматический батчинг в React 18

React 18 ввёл автоматический батчинг (Automatic Batching) — теперь обновления объединяются везде: в обработчиках событий, setTimeout, Promise, нативных обработчиках и т.д.

// React 18: батчинг работает везде
function App() {
  const [count, setCount] = useState(0);
  const [flag, setFlag] = useState(false);
  const [text, setText] = useState('');

  // Один рендер для всех трёх обновлений
  const handleClick = () => {
    setCount(c => c + 1);
    setFlag(f => !f);
    setText('обновлено');
    // Рендер происходит один раз после выхода из обработчика
  };

  // React 18: один рендер даже в setTimeout
  const handleDelayed = () => {
    setTimeout(() => {
      setCount(c => c + 1); // Не рендерит сразу
      setFlag(f => !f);     // Не рендерит сразу
      // Один рендер здесь
    }, 1000);
  };

  // React 18: один рендер даже в Promise
  const handleAsync = async () => {
    const data = await fetch('/api/data').then(r => r.json());
    setCount(data.count); // Не рендерит сразу
    setFlag(data.flag);   // Не рендерит сразу
    // Один рендер здесь
  };

  return (
    <div>
      <p>Count: {count}, Flag: {String(flag)}, Text: {text}</p>
      <button onClick={handleClick}>Синхронно</button>
      <button onClick={handleDelayed}>С задержкой</button>
      <button onClick={handleAsync}>Асинхронно</button>
    </div>
  );
}

Отключение батчинга с flushSync

В редких случаях вам может потребоваться принудительный синхронный рендер — например, для измерения DOM после обновления состояния.

import { flushSync } from 'react-dom';

function Example() {
  const [count, setCount] = useState(0);
  const [flag, setFlag] = useState(false);
  const listRef = useRef<HTMLUListElement>(null);

  const handleClick = () => {
    flushSync(() => {
      setCount(c => c + 1);
    });
    // DOM уже обновлён здесь
    console.log(listRef.current?.scrollHeight); // Актуальная высота

    flushSync(() => {
      setFlag(f => !f);
    });
    // Снова DOM обновлён
  };

  return (
    <div>
      <ul ref={listRef}>
        {Array.from({ length: count }).map((_, i) => <li key={i}>Элемент {i + 1}</li>)}
      </ul>
      <button onClick={handleClick}>Добавить с измерением</button>
    </div>
  );
}

Батчинг и React.startTransition

startTransition позволяет пометить обновления как некритические — React может отложить их рендер в пользу более важных обновлений.

import { useState, startTransition } from 'react';

function SearchComponent() {
  const [inputValue, setInputValue] = useState('');
  const [searchQuery, setSearchQuery] = useState('');

  const handleInput = (e: React.ChangeEvent<HTMLInputElement>) => {
    // Обновление поля ввода — критично, должно быть немедленным
    setInputValue(e.target.value);

    // Обновление поисковой строки — можно отложить
    startTransition(() => {
      setSearchQuery(e.target.value);
    });
  };

  return (
    <div>
      <input value={inputValue} onChange={handleInput} />
      {/* SearchResults использует searchQuery — рендерится с задержкой если нужно */}
      <SearchResults query={searchQuery} />
    </div>
  );
}

Управление состоянием форм

Стратегии управления формами

В React существует несколько подходов к формам:

  • Controlled components (управляемые компоненты) — состояние формы в React через useState
  • Uncontrolled components — состояние в DOM, доступ через ref

Для большинства форм рекомендуются управляемые компоненты.

Простая форма с валидацией

interface RegistrationForm {
  username: string;
  email: string;
  password: string;
  confirmPassword: string;
  agreeToTerms: boolean;
}

type FormErrors = Partial<Record<keyof RegistrationForm, string>>;

function RegistrationForm() {
  const [form, setForm] = useState<RegistrationForm>({
    username: '',
    email: '',
    password: '',
    confirmPassword: '',
    agreeToTerms: false,
  });

  const [errors, setErrors] = useState<FormErrors>({});
  const [isSubmitting, setIsSubmitting] = useState(false);
  const [submitError, setSubmitError] = useState<string | null>(null);
  const [isSuccess, setIsSuccess] = useState(false);

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const { name, value, type, checked } = e.target;
    const fieldValue = type === 'checkbox' ? checked : value;

    setForm(prev => ({ ...prev, [name]: fieldValue }));

    // Очищаем ошибку поля при вводе
    if (errors[name as keyof RegistrationForm]) {
      setErrors(prev => {
        const next = { ...prev };
        delete next[name as keyof RegistrationForm];
        return next;
      });
    }
  };

  const validate = (): boolean => {
    const newErrors: FormErrors = {};

    if (!form.username.trim()) {
      newErrors.username = 'Имя пользователя обязательно';
    } else if (form.username.length < 3) {
      newErrors.username = 'Минимум 3 символа';
    }

    if (!form.email.includes('@')) {
      newErrors.email = 'Введите корректный email';
    }

    if (form.password.length < 8) {
      newErrors.password = 'Пароль должен содержать минимум 8 символов';
    } else if (!/[A-Z]/.test(form.password)) {
      newErrors.password = 'Пароль должен содержать хотя бы одну заглавную букву';
    }

    if (form.password !== form.confirmPassword) {
      newErrors.confirmPassword = 'Пароли не совпадают';
    }

    if (!form.agreeToTerms) {
      newErrors.agreeToTerms = 'Необходимо принять условия';
    }

    setErrors(newErrors);
    return Object.keys(newErrors).length === 0;
  };

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();

    if (!validate()) return;

    setIsSubmitting(true);
    setSubmitError(null);

    try {
      await registerUser(form);
      setIsSuccess(true);
    } catch (error) {
      setSubmitError(error instanceof Error ? error.message : 'Ошибка регистрации');
    } finally {
      setIsSubmitting(false);
    }
  };

  if (isSuccess) {
    return <div>Регистрация прошла успешно!</div>;
  }

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <input
          name="username"
          value={form.username}
          onChange={handleChange}
          placeholder="Имя пользователя"
          disabled={isSubmitting}
        />
        {errors.username && <span className="error">{errors.username}</span>}
      </div>

      <div>
        <input
          name="email"
          type="email"
          value={form.email}
          onChange={handleChange}
          placeholder="Email"
          disabled={isSubmitting}
        />
        {errors.email && <span className="error">{errors.email}</span>}
      </div>

      <div>
        <input
          name="password"
          type="password"
          value={form.password}
          onChange={handleChange}
          placeholder="Пароль"
          disabled={isSubmitting}
        />
        {errors.password && <span className="error">{errors.password}</span>}
      </div>

      <div>
        <input
          name="confirmPassword"
          type="password"
          value={form.confirmPassword}
          onChange={handleChange}
          placeholder="Подтвердите пароль"
          disabled={isSubmitting}
        />
        {errors.confirmPassword && <span className="error">{errors.confirmPassword}</span>}
      </div>

      <div>
        <label>
          <input
            name="agreeToTerms"
            type="checkbox"
            checked={form.agreeToTerms}
            onChange={handleChange}
            disabled={isSubmitting}
          />
          Я принимаю условия использования
        </label>
        {errors.agreeToTerms && <span className="error">{errors.agreeToTerms}</span>}
      </div>

      {submitError && <div className="error">{submitError}</div>}

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Регистрация...' : 'Зарегистрироваться'}
      </button>
    </form>
  );
}

Многошаговая форма

type Step = 'personal' | 'address' | 'payment' | 'review';

const STEPS: Step[] = ['personal', 'address', 'payment', 'review'];

const STEP_LABELS: Record<Step, string> = {
  personal: 'Личные данные',
  address: 'Адрес',
  payment: 'Оплата',
  review: 'Подтверждение',
};

interface CheckoutData {
  personal: { name: string; email: string; phone: string };
  address: { street: string; city: string; zipCode: string };
  payment: { cardNumber: string; expiry: string; cvv: string };
}

function MultiStepForm() {
  const [currentStep, setCurrentStep] = useState<Step>('personal');
  const [data, setData] = useState<CheckoutData>({
    personal: { name: '', email: '', phone: '' },
    address: { street: '', city: '', zipCode: '' },
    payment: { cardNumber: '', expiry: '', cvv: '' },
  });

  const currentStepIndex = STEPS.indexOf(currentStep);

  const updateSection = <K extends keyof CheckoutData>(
    section: K,
    values: Partial<CheckoutData[K]>
  ) => {
    setData(prev => ({
      ...prev,
      [section]: { ...prev[section], ...values },
    }));
  };

  const goNext = () => {
    if (currentStepIndex < STEPS.length - 1) {
      setCurrentStep(STEPS[currentStepIndex + 1]);
    }
  };

  const goPrev = () => {
    if (currentStepIndex > 0) {
      setCurrentStep(STEPS[currentStepIndex - 1]);
    }
  };

  return (
    <div>
      {/* Индикатор шагов */}
      <div className="steps">
        {STEPS.map((step, idx) => (
          <div
            key={step}
            className={`step ${idx <= currentStepIndex ? 'active' : ''}`}
          >
            {STEP_LABELS[step]}
          </div>
        ))}
      </div>

      {/* Контент текущего шага */}
      {currentStep === 'personal' && (
        <PersonalStep
          data={data.personal}
          onChange={values => updateSection('personal', values)}
        />
      )}
      {currentStep === 'address' && (
        <AddressStep
          data={data.address}
          onChange={values => updateSection('address', values)}
        />
      )}
      {currentStep === 'payment' && (
        <PaymentStep
          data={data.payment}
          onChange={values => updateSection('payment', values)}
        />
      )}
      {currentStep === 'review' && (
        <ReviewStep data={data} />
      )}

      {/* Навигация */}
      <div className="navigation">
        <button onClick={goPrev} disabled={currentStepIndex === 0}>
          Назад
        </button>
        {currentStep === 'review' ? (
          <button onClick={() => handleSubmit(data)}>Оформить заказ</button>
        ) : (
          <button onClick={goNext}>Далее</button>
        )}
      </div>
    </div>
  );
}

Машины состояний с useState

Зачем моделировать машины состояний

Явное моделирование состояний через дискриминированные объединения (discriminated unions) делает логику компонента предсказуемой: в каждый момент времени компонент находится в ровно одном состоянии, и переходы между ними строго определены.

Простая машина состояний для асинхронного запроса

type FetchState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: string };

function useFetch<T>(url: string) {
  const [state, setState] = useState<FetchState<T>>({ status: 'idle' });

  const fetch_ = useCallback(async () => {
    setState({ status: 'loading' });

    try {
      const response = await fetch(url);
      if (!response.ok) throw new Error(`HTTP ${response.status}`);
      const data: T = await response.json();
      setState({ status: 'success', data });
    } catch (error) {
      setState({
        status: 'error',
        error: error instanceof Error ? error.message : 'Неизвестная ошибка',
      });
    }
  }, [url]);

  const reset = () => setState({ status: 'idle' });

  return { state, fetch: fetch_, reset };
}

// Использование с полной type safety
function UserProfile({ userId }: { userId: string }) {
  const { state, fetch, reset } = useFetch<User>(`/api/users/${userId}`);

  useEffect(() => {
    fetch();
  }, [fetch]);

  // TypeScript знает, какие поля доступны в каждом состоянии
  if (state.status === 'idle') {
    return <button onClick={fetch}>Загрузить профиль</button>;
  }

  if (state.status === 'loading') {
    return <div>Загрузка...</div>;
  }

  if (state.status === 'error') {
    return (
      <div>
        <p>Ошибка: {state.error}</p>
        <button onClick={reset}>Попробовать снова</button>
      </div>
    );
  }

  // Здесь TypeScript знает, что state.data существует
  return <div>Привет, {state.data.name}!</div>;
}

Машина состояний для UI-компонента

type ModalState =
  | { phase: 'closed' }
  | { phase: 'opening' }
  | { phase: 'open'; content: React.ReactNode }
  | { phase: 'closing' };

function AnimatedModal() {
  const [modalState, setModalState] = useState<ModalState>({ phase: 'closed' });

  const open = (content: React.ReactNode) => {
    setModalState({ phase: 'opening' });
    // После анимации открытия
    setTimeout(() => {
      setModalState({ phase: 'open', content });
    }, 300);
  };

  const close = () => {
    setModalState({ phase: 'closing' });
    // После анимации закрытия
    setTimeout(() => {
      setModalState({ phase: 'closed' });
    }, 300);
  };

  return (
    <div>
      <button onClick={() => open(<p>Содержимое модального окна</p>)}>
        Открыть
      </button>

      {modalState.phase !== 'closed' && (
        <div
          className={`modal modal--${modalState.phase}`}
          onClick={close}
        >
          {modalState.phase === 'open' && modalState.content}
        </div>
      )}
    </div>
  );
}

Машина состояний для игрового компонента

type GameState =
  | { phase: 'idle' }
  | { phase: 'countdown'; seconds: number }
  | { phase: 'playing'; score: number; timeLeft: number }
  | { phase: 'paused'; score: number; timeLeft: number }
  | { phase: 'gameover'; finalScore: number; highScore: number };

function GameComponent() {
  const [game, setGame] = useState<GameState>({ phase: 'idle' });

  const start = () => {
    setGame({ phase: 'countdown', seconds: 3 });
    const interval = setInterval(() => {
      setGame(prev => {
        if (prev.phase !== 'countdown') return prev;
        if (prev.seconds <= 1) {
          clearInterval(interval);
          return { phase: 'playing', score: 0, timeLeft: 60 };
        }
        return { ...prev, seconds: prev.seconds - 1 };
      });
    }, 1000);
  };

  const pause = () => {
    setGame(prev =>
      prev.phase === 'playing'
        ? { phase: 'paused', score: prev.score, timeLeft: prev.timeLeft }
        : prev
    );
  };

  const resume = () => {
    setGame(prev =>
      prev.phase === 'paused'
        ? { phase: 'playing', score: prev.score, timeLeft: prev.timeLeft }
        : prev
    );
  };

  const addScore = (points: number) => {
    setGame(prev =>
      prev.phase === 'playing'
        ? { ...prev, score: prev.score + points }
        : prev
    );
  };

  switch (game.phase) {
    case 'idle':
      return <button onClick={start}>Начать игру</button>;
    case 'countdown':
      return <div>Начало через {game.seconds}...</div>;
    case 'playing':
      return (
        <div>
          <p>Счёт: {game.score}</p>
          <p>Время: {game.timeLeft}</p>
          <button onClick={pause}>Пауза</button>
        </div>
      );
    case 'paused':
      return (
        <div>
          <p>Пауза. Счёт: {game.score}</p>
          <button onClick={resume}>Продолжить</button>
        </div>
      );
    case 'gameover':
      return (
        <div>
          <p>Игра окончена! Счёт: {game.finalScore}</p>
          <p>Рекорд: {game.highScore}</p>
          <button onClick={start}>Играть снова</button>
        </div>
      );
  }
}

Сброс состояния компонента через prop key

Как работает key

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

function ParentComponent() {
  const [formKey, setFormKey] = useState(0);

  const resetForm = () => {
    // Увеличиваем key — React размонтирует старую форму и монтирует новую
    setFormKey(prev => prev + 1);
  };

  return (
    <div>
      {/* При изменении key вся форма сбрасывается в начальное состояние */}
      <ComplexForm key={formKey} />
      <button onClick={resetForm}>Сбросить форму</button>
    </div>
  );
}

function ComplexForm() {
  const [name, setName] = useState('');
  const [email, setEmail] = useState('');
  const [step, setStep] = useState(1);
  const [files, setFiles] = useState<File[]>([]);

  // Всё это состояние будет сброшено автоматически при изменении key
  return (
    <form>
      <input value={name} onChange={e => setName(e.target.value)} />
      <input value={email} onChange={e => setEmail(e.target.value)} />
    </form>
  );
}

Сброс при изменении данных

interface UserEditorProps {
  userId: string;
}

function UserEditor({ userId }: UserEditorProps) {
  // Сброс состояния редактора при переключении между пользователями
  return <UserEditorInner key={userId} userId={userId} />;
}

function UserEditorInner({ userId }: UserEditorProps) {
  const [user, setUser] = useState<User | null>(null);
  const [isDirty, setIsDirty] = useState(false);
  const [editedData, setEditedData] = useState<Partial<User>>({});

  useEffect(() => {
    // Загружаем данные пользователя
    fetchUser(userId).then(setUser);
  }, [userId]);

  // При изменении userId компонент размонтируется и монтируется заново
  // isDirty и editedData автоматически сбрасываются в начальные значения
  return (
    <div>
      {user && (
        <form>
          <input
            value={editedData.name ?? user.name}
            onChange={e => {
              setEditedData(prev => ({ ...prev, name: e.target.value }));
              setIsDirty(true);
            }}
          />
          {isDirty && <span>Есть несохранённые изменения</span>}
        </form>
      )}
    </div>
  );
}

key vs явный сброс состояния

// Подход 1: key (проще, полный сброс)
function WithKeyReset() {
  const [key, setKey] = useState(0);
  return (
    <div>
      <Child key={key} />
      <button onClick={() => setKey(k => k + 1)}>Сбросить</button>
    </div>
  );
}

// Подход 2: явный сброс (больше контроля)
function WithManualReset() {
  const [name, setName] = useState('');
  const [count, setCount] = useState(0);

  const reset = () => {
    setName('');
    setCount(0);
  };

  return (
    <div>
      <input value={name} onChange={e => setName(e.target.value)} />
      <p>Count: {count}</p>
      <button onClick={() => setCount(c => c + 1)}>+</button>
      <button onClick={reset}>Сбросить</button>
    </div>
  );
}

Используйте key когда нужно сбросить всё состояние компонента (включая вложенные компоненты). Используйте явный сброс, когда нужно сбросить часть состояния или выполнить дополнительные действия при сбросе.

Когда переходить к useReducer

Сигналы к рефакторингу

useState отлично справляется с независимыми, простыми значениями. Когда начинают появляться следующие паттерны, это сигнал рассмотреть useReducer:

Сигнал 1: Много связанных состояний

// Слишком много useState — каждое обновление требует координации
function DataGrid() {
  const [rows, setRows] = useState<Row[]>([]);
  const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set());
  const [sortColumn, setSortColumn] = useState<string | null>(null);
  const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
  const [currentPage, setCurrentPage] = useState(1);
  const [pageSize, setPageSize] = useState(25);
  const [filters, setFilters] = useState<Record<string, string>>({});
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);
  // 9 состояний — пора переходить на useReducer
}

Сигнал 2: Сложные переходы с несколькими обновлениями

// Плохо: разрозненные обновления, легко забыть одно из них
const handleSort = (column: string) => {
  if (sortColumn === column) {
    setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc');
  } else {
    setSortColumn(column);
    setSortDirection('asc');
    setCurrentPage(1); // Не забыть сбросить страницу!
    setSelectedRows(new Set()); // И выделение!
  }
};

// Хорошо: с useReducer вся логика в одном месте
dispatch({ type: 'SORT_CHANGED', payload: column });

Сигнал 3: Состояние передаётся вниз для обновления

// Плохо: много пропов-функций
<DataTable
  rows={rows}
  onRowSelect={handleRowSelect}
  onRowDeselect={handleRowDeselect}
  onSelectAll={handleSelectAll}
  onClearSelection={handleClearSelection}
  onSort={handleSort}
  onPageChange={handlePageChange}
  onFilterChange={handleFilterChange}
/>

// Хорошо: один dispatch
<DataTable rows={rows} dispatch={dispatch} />

Рефакторинг useState → useReducer

// До: набор useState
function ShoppingCart() {
  const [items, setItems] = useState<CartItem[]>([]);
  const [coupon, setCoupon] = useState<string | null>(null);
  const [isCheckingOut, setIsCheckingOut] = useState(false);

  const addItem = (product: Product) => {
    setItems(prev => {
      const existing = prev.find(i => i.productId === product.id);
      if (existing) {
        return prev.map(i =>
          i.productId === product.id ? { ...i, qty: i.qty + 1 } : i
        );
      }
      return [...prev, { productId: product.id, product, qty: 1 }];
    });
  };

  const removeItem = (productId: string) => {
    setItems(prev => prev.filter(i => i.productId !== productId));
  };

  // ... много других обработчиков
}

// После: useReducer
interface CartState {
  items: CartItem[];
  coupon: string | null;
  isCheckingOut: boolean;
}

type CartAction =
  | { type: 'ADD_ITEM'; payload: Product }
  | { type: 'REMOVE_ITEM'; payload: string }
  | { type: 'UPDATE_QTY'; payload: { productId: string; qty: number } }
  | { type: 'APPLY_COUPON'; payload: string }
  | { type: 'REMOVE_COUPON' }
  | { type: 'START_CHECKOUT' }
  | { type: 'CANCEL_CHECKOUT' }
  | { type: 'CLEAR_CART' };

function cartReducer(state: CartState, action: CartAction): CartState {
  switch (action.type) {
    case 'ADD_ITEM': {
      const existing = state.items.find(i => i.productId === action.payload.id);
      if (existing) {
        return {
          ...state,
          items: state.items.map(i =>
            i.productId === action.payload.id ? { ...i, qty: i.qty + 1 } : i
          ),
        };
      }
      return {
        ...state,
        items: [...state.items, { productId: action.payload.id, product: action.payload, qty: 1 }],
      };
    }
    case 'REMOVE_ITEM':
      return { ...state, items: state.items.filter(i => i.productId !== action.payload) };
    case 'APPLY_COUPON':
      return { ...state, coupon: action.payload };
    case 'START_CHECKOUT':
      return { ...state, isCheckingOut: true };
    case 'CLEAR_CART':
      return { items: [], coupon: null, isCheckingOut: false };
    default:
      return state;
  }
}

Сравнение: когда что выбирать

Критерий useState useReducer
Количество состояний 1-3 независимых 4+ или связанные
Логика переходов Простая Сложная, условная
Тестируемость Средняя Высокая (редьюсер — чистая функция)
Читаемость Хорошая для простых случаев Лучше для сложных
Отладка Стандартная Удобнее (action log)
Переиспользование логики Сложнее Проще (редьюсер отдельно)

TypeScript типизация useState

Базовые типы

TypeScript может автоматически вывести тип из начального значения. Но иногда явная аннотация необходима.

// Автоматический вывод типов
const [count, setCount] = useState(0);          // number
const [name, setName] = useState('');           // string
const [active, setActive] = useState(false);   // boolean
const [items, setItems] = useState([]);         // never[] — проблема!

// Явная аннотация (рекомендуется для массивов и объектов)
const [items, setItems] = useState<string[]>([]); // string[]
const [data, setData] = useState<number[] | null>(null); // number[] | null

Nullable типы и начальное значение null

interface User {
  id: string;
  name: string;
  email: string;
}

// Правильный паттерн для данных, которые загружаются асинхронно
const [user, setUser] = useState<User | null>(null);
const [users, setUsers] = useState<User[]>([]);

// После загрузки
setUser({ id: '1', name: 'Иван', email: 'ivan@example.com' });

// Проверка перед использованием
if (user !== null) {
  console.log(user.name); // TypeScript знает, что user не null
}

Union типы и дискриминированные объединения

// Простой union для конечного числа вариантов
type Status = 'idle' | 'loading' | 'success' | 'error';
const [status, setStatus] = useState<Status>('idle');

// Дискриминированный union для разных состояний с разными данными
type RequestState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T; loadedAt: Date }
  | { status: 'error'; message: string; code: number };

function useRequest<T>() {
  const [state, setState] = useState<RequestState<T>>({ status: 'idle' });

  const execute = async (fetcher: () => Promise<T>) => {
    setState({ status: 'loading' });
    try {
      const data = await fetcher();
      setState({ status: 'success', data, loadedAt: new Date() });
    } catch (error) {
      setState({
        status: 'error',
        message: error instanceof Error ? error.message : 'Ошибка',
        code: 500,
      });
    }
  };

  return { state, execute };
}

Generic компоненты с useState

// Generic хук для управления выбором из списка
function useSelection<T extends { id: string }>(items: T[]) {
  const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());

  const select = (id: string) => {
    setSelectedIds(prev => new Set([...prev, id]));
  };

  const deselect = (id: string) => {
    setSelectedIds(prev => {
      const next = new Set(prev);
      next.delete(id);
      return next;
    });
  };

  const toggle = (id: string) => {
    setSelectedIds(prev => {
      const next = new Set(prev);
      if (next.has(id)) next.delete(id);
      else next.add(id);
      return next;
    });
  };

  const selectAll = () => {
    setSelectedIds(new Set(items.map(i => i.id)));
  };

  const clearSelection = () => {
    setSelectedIds(new Set());
  };

  const selectedItems = items.filter(i => selectedIds.has(i.id));
  const isAllSelected = items.length > 0 && selectedIds.size === items.length;

  return {
    selectedIds,
    selectedItems,
    isAllSelected,
    select,
    deselect,
    toggle,
    selectAll,
    clearSelection,
  };
}

// Использование
interface Product {
  id: string;
  name: string;
  price: number;
}

function ProductList({ products }: { products: Product[] }) {
  const { selectedItems, toggle, selectAll, clearSelection } = useSelection(products);

  return (
    <div>
      <button onClick={selectAll}>Выбрать всё</button>
      <button onClick={clearSelection}>Сбросить выбор</button>
      <p>Выбрано: {selectedItems.length}</p>
      {products.map(p => (
        <div key={p.id} onClick={() => toggle(p.id)}>
          {p.name}
        </div>
      ))}
    </div>
  );
}

Типизация setState функции

// Тип функции обновления состояния
type SetState<T> = React.Dispatch<React.SetStateAction<T>>;

// React.SetStateAction<T> — это T | ((prevState: T) => T)
// Поэтому setState принимает и значение, и функцию

// Передача setState как prop
interface ControlledInputProps {
  value: string;
  onChange: SetState<string>;
}

function ControlledInput({ value, onChange }: ControlledInputProps) {
  return (
    <input
      value={value}
      onChange={e => onChange(e.target.value)}
    />
  );
}

// Использование
function Parent() {
  const [text, setText] = useState('');
  return <ControlledInput value={text} onChange={setText} />;
}

Распространённые ошибки и антипаттерны

Антипаттерн 1: Хранение производных данных в состоянии

// Плохо: дублирование и рассинхронизация
function Cart() {
  const [items, setItems] = useState<CartItem[]>([]);
  const [total, setTotal] = useState(0); // Производное значение — не нужен useState!

  const addItem = (item: CartItem) => {
    const newItems = [...items, item];
    setItems(newItems);
    setTotal(newItems.reduce((sum, i) => sum + i.price * i.qty, 0)); // Риск рассинхронизации
  };
}

// Хорошо: вычисляем при рендере
function Cart() {
  const [items, setItems] = useState<CartItem[]>([]);
  const total = items.reduce((sum, i) => sum + i.price * i.qty, 0); // Всегда актуально

  const addItem = (item: CartItem) => {
    setItems(prev => [...prev, item]);
    // total обновится автоматически при следующем рендере
  };
}

Антипаттерн 2: Синхронизация состояний через useEffect

// Плохо: синхронизация через эффект
function FilteredList({ items }: { items: Item[] }) {
  const [filter, setFilter] = useState('');
  const [filteredItems, setFilteredItems] = useState(items);

  // Это antipattern: filteredItems всегда можно вычислить из items и filter
  useEffect(() => {
    setFilteredItems(items.filter(i => i.name.includes(filter)));
  }, [items, filter]);

  return <ul>{filteredItems.map(i => <li key={i.id}>{i.name}</li>)}</ul>;
}

// Хорошо: вычисляем напрямую
function FilteredList({ items }: { items: Item[] }) {
  const [filter, setFilter] = useState('');
  const filteredItems = items.filter(i => i.name.includes(filter)); // Вычисляем при рендере

  return <ul>{filteredItems.map(i => <li key={i.id}>{i.name}</li>)}</ul>;
}

// Если вычисление дорогостоящее — используем useMemo
function FilteredList({ items }: { items: Item[] }) {
  const [filter, setFilter] = useState('');
  const filteredItems = useMemo(
    () => items.filter(i => i.name.includes(filter)),
    [items, filter]
  );

  return <ul>{filteredItems.map(i => <li key={i.id}>{i.name}</li>)}</ul>;
}

Антипаттерн 3: Копирование props в состояние

// Плохо: состояние не синхронизируется при изменении prop
function UserDisplay({ name }: { name: string }) {
  const [displayName, setDisplayName] = useState(name); // Копирует значение при монтировании

  // Если name изменится в родителе, displayName останется старым!
  return <h1>{displayName}</h1>;
}

// Хорошо 1: использовать prop напрямую
function UserDisplay({ name }: { name: string }) {
  return <h1>{name}</h1>;
}

// Хорошо 2: если нужно редактирование, явно называем initialName
function UserEditor({ initialName }: { initialName: string }) {
  const [name, setName] = useState(initialName); // Явно — начальное значение

  return <input value={name} onChange={e => setName(e.target.value)} />;
}

// Хорошо 3: использовать key для сброса при изменении userId
function UserEditorWithReset({ userId, initialName }: { userId: string; initialName: string }) {
  return <UserEditorInner key={userId} initialName={initialName} />;
}

Антипаттерн 4: useState внутри условий и циклов

// Плохо: нарушение Rules of Hooks
function BadComponent({ show }: { show: boolean }) {
  if (show) {
    const [value, setValue] = useState(''); // Ошибка! Хуки не могут быть условными
  }

  for (let i = 0; i < 3; i++) {
    const [item, setItem] = useState(''); // Ошибка! Хуки не могут быть в циклах
  }
}

// Хорошо: хуки всегда на верхнем уровне
function GoodComponent({ show }: { show: boolean }) {
  const [value, setValue] = useState(''); // Всегда вызывается
  // Используем show только в JSX:
  return show ? <input value={value} onChange={e => setValue(e.target.value)} /> : null;
}

Антипаттерн 5: Слишком гранулярное состояние

// Плохо: каждое поле формы в отдельном useState
function OverGranularForm() {
  const [firstName, setFirstName] = useState('');
  const [lastName, setLastName] = useState('');
  const [email, setEmail] = useState('');
  const [phone, setPhone] = useState('');
  const [street, setStreet] = useState('');
  const [city, setCity] = useState('');
  // 10+ отдельных useState — сложно управлять

  const reset = () => {
    setFirstName('');
    setLastName('');
    setEmail('');
    setPhone('');
    setStreet('');
    setCity('');
    // Легко забыть одно из полей
  };
}

// Хорошо: группируем логически связанные поля
function GoodForm() {
  const [personal, setPersonal] = useState({ firstName: '', lastName: '', email: '', phone: '' });
  const [address, setAddress] = useState({ street: '', city: '', zipCode: '' });

  const reset = () => {
    setPersonal({ firstName: '', lastName: '', email: '', phone: '' });
    setAddress({ street: '', city: '', zipCode: '' });
  };
}

Антипаттерн 6: Ненужные промежуточные состояния

// Плохо: храним и флаг, и данные, хотя данные уже содержат информацию о состоянии
function UserLoader() {
  const [user, setUser] = useState<User | null>(null);
  const [isLoaded, setIsLoaded] = useState(false); // Лишнее! user !== null уже означает isLoaded

  useEffect(() => {
    fetchUser().then(u => {
      setUser(u);
      setIsLoaded(true); // Лишний вызов
    });
  }, []);

  return isLoaded ? <UserCard user={user!} /> : <Skeleton />;
}

// Хорошо: используем null как индикатор загрузки
function UserLoader() {
  const [user, setUser] = useState<User | null>(null);

  useEffect(() => {
    fetchUser().then(setUser);
  }, []);

  return user !== null ? <UserCard user={user} /> : <Skeleton />;
}

Оптимизация производительности

Предотвращение лишних рендеров

// Проблема: каждое изменение родителя перерендеривает дочерний компонент
function Parent() {
  const [count, setCount] = useState(0);
  const [name, setName] = useState('');

  return (
    <div>
      <input value={name} onChange={e => setName(e.target.value)} />
      <p>{count}</p>
      <ExpensiveChild /> {/* Рендерится при каждом вводе в поле! */}
    </div>
  );
}

// Решение 1: React.memo
const ExpensiveChild = React.memo(function ExpensiveChild() {
  // Рендерится только при изменении своих props
  return <div>Дорогой компонент</div>;
});

// Решение 2: Разделение состояния на отдельные компоненты
function NameInput() {
  const [name, setName] = useState(''); // Только этот компонент рендерится при вводе
  return <input value={name} onChange={e => setName(e.target.value)} />;
}

function Counter() {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}

Оптимизация начальной инициализации

// Тяжёлая инициализация без оптимизации
function SlowComponent() {
  // generateInitialData() вызывается при КАЖДОМ рендере!
  const [data, setData] = useState(generateInitialData()); // Плохо
}

// С ленивой инициализацией
function FastComponent() {
  // generateInitialData() вызывается ТОЛЬКО при монтировании
  const [data, setData] = useState(() => generateInitialData()); // Хорошо
}

Стабилизация обратных вызовов

// Проблема: новая функция при каждом рендере
function Parent() {
  const [count, setCount] = useState(0);

  // Эта функция создаётся заново при каждом рендере
  const handleEvent = () => setCount(c => c + 1);

  // Child получает новую ссылку на handleEvent и перерендеривается
  return <MemoizedChild onEvent={handleEvent} />;
}

// Решение: useCallback для стабилизации
function Parent() {
  const [count, setCount] = useState(0);

  // Функция стабильна между рендерами
  const handleEvent = useCallback(() => {
    setCount(c => c + 1); // Функциональная форма — не нужен count в deps
  }, []); // Пустые зависимости — функция не меняется

  return <MemoizedChild onEvent={handleEvent} />;
}

Разделение крупного состояния

// Плохо: одно большое состояние — любое изменение вызывает полный рендер
function Dashboard() {
  const [state, setState] = useState({
    user: null as User | null,
    notifications: [] as Notification[],
    settings: defaultSettings,
    ui: { sidebarOpen: true, activeTab: 'dashboard' },
    analytics: { visitors: 0, revenue: 0 },
  });
}

// Хорошо: разделяем на логически независимые части
function Dashboard() {
  const [user, setUser] = useState<User | null>(null);
  const [notifications, setNotifications] = useState<Notification[]>([]);
  const [settings, setSettings] = useState(defaultSettings);
  const [ui, setUi] = useState({ sidebarOpen: true, activeTab: 'dashboard' });
  const [analytics, setAnalytics] = useState({ visitors: 0, revenue: 0 });

  // Изменение notifications не перерендерит компоненты, зависящие только от user
}

useDeferredValue для плавного UI

import { useState, useDeferredValue, useMemo } from 'react';

function SearchPage() {
  const [query, setQuery] = useState('');
  // deferredQuery обновляется "позже" — после срочных обновлений
  const deferredQuery = useDeferredValue(query);

  // Тяжёлая фильтрация откладывается
  const results = useMemo(
    () => heavyFilter(allItems, deferredQuery),
    [deferredQuery]
  );

  // Индикатор устаревших данных
  const isStale = query !== deferredQuery;

  return (
    <div>
      <input value={query} onChange={e => setQuery(e.target.value)} />
      {/* Поле ввода реагирует мгновенно, результаты — с задержкой */}
      <div style={{ opacity: isStale ? 0.5 : 1 }}>
        {results.map(item => <div key={item.id}>{item.name}</div>)}
      </div>
    </div>
  );
}

Заключение

Продвинутое использование useState — это не просто набор трюков, а понимание того, как React управляет состоянием на низком уровне. Ключевые выводы:

  • Ленивая инициализация устраняет лишние вычисления при каждом рендере
  • Функциональные обновления защищают от race conditions в асинхронном коде
  • Иммутабельность — основное правило при работе с объектами и массивами
  • Батчинг в React 18 автоматически оптимизирует несколько обновлений в один рендер
  • Машины состояний делают логику предсказуемой и тестируемой
  • key для сброса — элегантный способ полного сброса состояния компонента
  • TypeScript типизация даёт compile-time безопасность и улучшает DX
  • Производные данные не должны храниться в состоянии — вычисляйте их при рендере

Если вы чувствуете, что useState уже не справляется — это сигнал рассмотреть useReducer для сложной логики или вынести состояние во внешнее хранилище.

Чтобы освоить управление состоянием React в полном объёме — от простых хуков до Redux Toolkit — приходите на курс Основы React, React Router и Redux Toolkit. Первые 3 модуля доступны бесплатно.

Частозадаваемые вопросы

Почему setState не обновляет значение немедленно в той же функции?

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

const nextCount = count + 1;
setCount(nextCount);
console.log(nextCount); // Актуальное значение доступно здесь

Как сравниваются значения при вызове setState?

React использует Object.is() для сравнения нового и старого значений. Если они одинаковы, рендер пропускается. Для объектов и массивов сравниваются ссылки, а не содержимое.

// Рендер не запустится — та же строка
setName('Иван');
setName('Иван');

// Рендер запустится — новый объект, другая ссылка
setUser({ ...user }); // Даже если содержимое одинаковое

Можно ли хранить функцию в useState?

Да, но нужно использовать обёрточную функцию, иначе React интерпретирует функцию как ленивый инициализатор:

// Плохо: React вызовет myFn как инициализатор и сохранит результат
const [fn, setFn] = useState(myFn); // Сохранится myFn(), а не myFn

// Хорошо: оборачиваем в функцию
const [fn, setFn] = useState(() => myFn); // Сохранится сама функция myFn
setFn(() => newFn); // Обновление тоже через обёртку

Как избежать бесконечного цикла в useEffect с setState?

Убедитесь, что не добавляете изменяемое состояние в зависимости эффекта, если эффект его же и обновляет:

// Бесконечный цикл!
useEffect(() => {
  setCount(count + 1); // Обновляет count
}, [count]); // count в зависимостях — эффект запускается снова

// Правильно: функциональная форма, убираем count из зависимостей
useEffect(() => {
  setCount(prev => prev + 1); // Не нужен count в замыкании
}, []); // Пустые зависимости — запускается один раз

Когда использовать useState, а когда useRef?

useState вызывает рендер при обновлении. useRef — нет. Используйте useRef для значений, которые не влияют на отображение:

const [count, setCount] = useState(0); // Влияет на UI — useState
const timerRef = useRef<number | null>(null); // Не влияет на UI — useRef
const prevCountRef = useRef(count); // Хранение предыдущего значения — useRef

Дополнительные ресурсы

Стрелочка влевоКастомные хуки в React — создание собственных хуков

Постройте личный план изучения React до уровня Middle — бесплатно!

React — часть карты развития Frontend

  • step100+ шагов развития
  • lessons30 бесплатных лекций
  • lessons300 бонусных рублей на счет

Бесплатные лекции

Все гайды по React

Render Props: гибкое управление рендерингом в ReactUncontrolled Components: когда DOM управляет даннымиПрофилирование React: как найти и устранить узкие местаЧастичное применение: как создавать компоненты без лишнего кодаЛенивая загрузка: как ускорить React-приложение в разыHOC в React: мастерство композиции компонентовuseMemo: как спасти производительность от тяжелых вычисленийError Boundaries: создаем надежные React-приложенияCompound Components в React: создаем гибкие компоненты с мощным APIКонтролируемые компоненты в React: полный контроль над формамиКомпозиция компонентов в React: строим гибкие интерфейсыCode Splitting в React: как уменьшить бандл и ускорить загрузку приложенияАсинхронные компоненты в React: новый стандарт работы с данными
useState в React что это и как использоватьuseTransition - плавные переходы между состояниямиuseSyncExternalStore — работа с внешними сторамиuseRef в React — создание ссылок на DOM и значенияuseLayoutEffect в React — эффект до отрисовкиuseInsertionEffect — внедрение стилей до мутаций DOMuseImperativeHandle в React — настройка ref дочернего компонентаuseId — генерация уникальных идентификаторовuseDeferredValue — отложенное обновление состоянияuseDebugValue — отладка кастомных хуковuseCallback в React — мемоизация функцийuseReducer — альтернатива useState для сложной логикиuseMemo в React: как и когда оптимизировать тяжелые вычисленияuseEffect в React что это и как использоватьuseContext — работа с контекстом в ReactОптимизация рендеринга в React: от теории к глубокой практикеЧто такое useRef и как его применять в ReactКак и зачем использовать React HooksУправление состоянием в React через ContextКак предотвратить лишние ре-рендеры в React: полное руководствоuseMemo vs useCallback: подробное руководство по мемоизации в ReactПравила хуков — правила использованияuseEffect vs useLayoutEffect: в чём разница и какой хук выбрать?Кастомные хуки в React — создание собственных хуковuseState продвинутое использование в React
StrictMode в React — как находить ошибки на этапе разработкиСерверные компоненты React (RSC) — подробный разбор и практикаКак работает рендеринг в ReactЧто такое props в React и как их правильно использоватьКак работает JSX связка React и HTMLЧто такое React.js и как его использоватьКак использовать элементы в ReactКак использовать React DOM в проектеЧто такое компоненты в React и как их применятьРабота с children в ReactПорталы в React: рендер компонентов вне иерархии DOMFragment в React: группировка элементов без лишних узлов DOM
Открыть базу знаний

Лучшие курсы по теме

изображение курса

React и Redux Toolkit

Антон Ларичев
AI-тренажеры
Практика в студии
Гарантия
Бонусы
иконка звёздочки рейтинга4.8
3 999 ₽ 6 990 ₽
Подробнее
изображение курса

TypeScript с нуля

Антон Ларичев
AI-тренажеры
Практика в студии
Гарантия
Бонусы
иконка звёздочки рейтинга4.8
3 999 ₽ 6 990 ₽
Подробнее
изображение курса

Next.js - с нуля

Антон Ларичев
AI-тренажеры
Практика в студии
Гарантия
Бонусы
иконка звёздочки рейтинга4.7
3 999 ₽ 6 990 ₽
Подробнее

Отправить комментарий