О разделе

Три технологии, которые почти всегда идут вместе. Разбор сосредоточен на том, что чаще всего ломается: зависимости эффектов, лишние перерисовки, граница между типами и рантаймом, разрастание классов в разметке.

Параллель для тех, кто знает Vue. useState ≈ ref, useEffect ≈ watch + onMounted, useMemo ≈ computed. Главное отличие: во Vue реактивность отслеживается автоматически, в React зависимости перечисляются руками — отсюда почти все баги с устаревшими данными.
JSX и как React рисует экран
JSX — это вызовы функций, а не шаблон

JSX не является частью JavaScript. Сборщик (Vite, esbuild, tsc) превращает его в обычные вызовы функции. Понимать это важно: отсюда следуют все правила — почему атрибут называется className, почему нельзя вернуть два элемента подряд и почему условие пишется выражением, а не оператором.

// Что вы пишете
const el = <button className="btn" onClick={save}>Сохранить</button>;

// Во что это превращается (новый JSX-трансформ, React 17+)
import { jsx as _jsx } from 'react/jsx-runtime';
const el = _jsx('button', { className: 'btn', onClick: save, children: 'Сохранить' });

// Результат — обычный объект, описание того, что нужно нарисовать:
// { type: 'button', props: { className: 'btn', onClick: f, children: 'Сохранить' } }
Ключевая мысль. Компонент не рисует DOM. Он возвращает описание желаемого результата, а React сам решает, какие узлы DOM создать, обновить или удалить. Поэтому один и тот же компонент работает и в браузере, и при серверном рендеринге, и в React Native.
В HTMLВ JSXПочему
classclassNameclass — зарезервированное слово JavaScript
forhtmlForfor — оператор цикла
onclick="..."onClick={fn}Передаётся функция, а не строка кода
style="color:red"style={{ color: 'red' }}Объект свойств, имена в camelCase
<!-- … -->{/* … */}Комментарий — это выражение JavaScript
Фигурные скобки: только выражения

В {} подставляется выражение — то, что имеет значение. Оператор if или цикл for значения не имеют, поэтому внутри JSX используют тернарный оператор, && и map.

function OrderStatus({ order, user }) {
  return (
    <div>
      {/* тернарный — когда нужны обе ветки */}
      {order.paid ? <Badge kind="ok">Оплачен</Badge> : <Badge>Ожидает оплаты</Badge>}

      {/* && — когда ветка одна */}
      {user.isAdmin && <AdminPanel orderId={order.id} />}

      {/* список — через map, не через for */}
      <ul>
        {order.items.map(item => <li key={item.id}>{item.title}</li>)}
      </ul>
    </div>
  );
}
Ноль вместо пустоты. {items.length && <List />} при пустом массиве выведет на экран 0: 0 && x возвращает 0, а React печатает числа. React не рисует только false, null и undefined. Пишите items.length > 0 && ….
Объект в разметке. {user}, где user — объект, роняет рендер с «Objects are not valid as a React child». Нужно выбрать поле: {user.name}.
Один корень и фрагменты

Функция возвращает одно значение, поэтому и компонент возвращает один элемент. Когда лишняя обёртка <div> ломает вёрстку (например, внутри grid или таблицы), используют фрагмент.

// Ошибка сборки: два корневых элемента
return (
  <td>{row.name}</td>
  <td>{row.total}</td>
);

// Фрагмент — не создаёт узла в DOM
return (
  <>
    <td>{row.name}</td>
    <td>{row.total}</td>
  </>
);

// Если нужен key — только полная форма
{rows.map(row => (
  <React.Fragment key={row.id}>
    <dt>{row.name}</dt>
    <dd>{row.total}</dd>
  </React.Fragment>
))}
Что происходит при обновлении

Когда состояние меняется, React вызывает компонент заново, получает новое описание и сравнивает его с предыдущим. В DOM уходят только отличия — это и называют согласованием (reconciliation).

setState(…) │ ├─ 1. React помечает компонент как требующий обновления │ ├─ 2. Вызывает функцию компонента → получает новое дерево описаний │ ├─ 3. Сравнивает с предыдущим деревом: │ • тип узла изменился → старый узел удаляется, создаётся новый │ • тип тот же → обновляются только изменившиеся атрибуты │ • узлы списка → сопоставляются по key │ └─ 4. Применяет минимальный набор правок к настоящему DOM (эта фаза называется commit)

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

Условная обёртка сбрасывает состояние. Если один и тот же <Form /> в одной ветке условия обёрнут в <div>, а в другой нет — React видит разный тип на этой позиции, уничтожает старое поддерево и создаёт новое. Всё внутреннее состояние формы теряется.
useState и useEffect
Что такое хук и почему правила именно такие

Хук — функция, которая даёт компоненту доступ к возможностям React: памяти между рендерами, побочным эффектам, контексту. Все они начинаются с use.

React не знает имён ваших переменных. Он хранит состояние компонента списком и на каждом рендере выдаёт значения по порядку вызовов. Отсюда и единственное жёсткое правило: порядок вызовов хуков должен быть одинаковым при каждом рендере.

Первый рендер Второй рендер ────────────────────── ────────────────────── useState('') → ячейка 0 useState('') → ячейка 0 useState(0) → ячейка 1 useState(0) → ячейка 1 useEffect(...) → ячейка 2 useEffect(...) → ячейка 2 А если хук спрятан в условии: if (isOpen) useState(''); ← на первом рендере ячейка 0 занята, useState(0); на втором — сдвиг, и значения разъезжаются по чужим ячейкам
ПравилоЧто произойдёт при нарушении
Только на верхнем уровне функцииХук в if, цикле или после раннего return сдвигает нумерацию — React выбрасывает ошибку о изменившемся порядке
Только из компонента или другого хукаВызов из обычной функции или обработчика события: React не поймёт, какому компоненту принадлежит состояние
Имя начинается с useФормально не обязательно, но линтер перестанет проверять зависимости и правила
Для разработчика на PHP. Привычка «объявлю переменную внутри if» здесь не работает. Компонент вызывается заново десятки раз, а состояние живёт снаружи него — в React. Хуки и есть способ дотянуться до этой внешней памяти, и дотягиваться нужно всегда в одном порядке.
useState: снимок, а не переменная

Значение состояния в конкретном рендере — это снимок. Вызов функции-установщика не меняет уже существующую переменную, а просит React отрисовать компонент заново с новым значением.

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

function handleClick() {
  setCount(count + 1);
  setCount(count + 1);
  setCount(count + 1);
  // count здесь всё ещё 0 — это снимок текущего рендера.
  // Результат: 1, а не 3.
}

function handleClickRight() {
  setCount(c => c + 1);
  setCount(c => c + 1);
  setCount(c => c + 1);
  // Функциональная форма: каждая получает актуальное значение. Результат: 3.
}

Несколько вызовов подряд React объединяет в одну перерисовку — это называют пакетированием (batching). Начиная с React 18 оно работает и внутри промисов, и в таймерах, а не только в обработчиках событий.

// Тяжёлое начальное значение: так функция выполняется на каждом рендере
const [rows, setRows] = useState(parseHugeCsv(raw));

// Ленивая инициализация: функция выполнится ровно один раз
const [rows, setRows] = useState(() => parseHugeCsv(raw));
Чтение состояния сразу после установки. setCount(5); console.log(count); выведет старое значение. Новое появится только в следующем рендере — это не задержка, а то же самое свойство снимка.
Мутация вместо замены. items.push(x); setItems(items) не перерисует компонент: ссылка на массив прежняя. Нужен новый массив — setItems([...items, x]).
Когда useEffect не нужен

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

ЗадачаЧерез эффектКак правильно
Значение выводится из props или состоянияЛишний рендер и риск рассинхронаПосчитать прямо при рендере
Расчёт дорогой—useMemo
Реакция на действие пользователяЛогика размазана между кликом и эффектомПрямо в обработчике события
Сбросить состояние при смене сущностиuseEffect со сравнением idПередать key компоненту — React пересоздаст его
// Лишний эффект: два рендера и состояние, которое может разъехаться
const [fullName, setFullName] = useState('');
useEffect(() => { setFullName(first + ' ' + last); }, [first, last]);

// Обычное вычисление — всегда согласовано
const fullName = first + ' ' + last;

// Сброс формы эффектом при смене пользователя
useEffect(() => { setDraft(''); }, [userId]);

// Новый key — новый экземпляр компонента со свежим состоянием
<ProfileForm key={userId} userId={userId} />
Два вызова эффекта в разработке. В строгом режиме React монтирует компонент, размонтирует и монтирует снова, чтобы проверить корректность очистки. Это не ошибка: если эффект нельзя запустить дважды без последствий, в нём не хватает очистки.
Зависимости и очистка

Эффект перезапускается при изменении зависимостей. Возвращаемая функция — очистка: отписка от сокета, clearInterval, AbortController для fetch.

useEffect(() => {
  const controller = new AbortController();

  fetch(`/api/chats/${chatId}`, { signal: controller.signal })
    .then(r => r.json())
    .then(setMessages)
    .catch(e => { if (e.name !== 'AbortError') report(e); });

  return () => controller.abort();   // очистка при смене chatId или размонтировании
}, [chatId]);
Массив зависимостейКогда запускается
[]Только при монтировании
[a, b]При монтировании и при изменении a или b
Не указанПосле каждой перерисовки — почти всегда ошибка
Устаревшее замыкание (stale closure). Эффект или колбэк захватывает state того рендера, в котором был создан. Если зависимость не указана, внутри останется старое значение — и код будет работать «через раз». Лечится честными зависимостями или функциональной формой: setCount(c => c + 1) вместо setCount(count + 1).
Гонка запросов. Быстро переключили чат — ответ по старому chatId придёт позже нового и перетрёт данные. Отмена через AbortController в очистке решает это.
Перерисовки и мемоизация
Почему компонент перерисовывается
  • Изменился его state
  • Изменились props
  • Изменилось значение контекста, который он читает
  • Перерисовался родитель — даже если props те же
ИнструментЧто делает
React.memoПропускает перерисовку, если props не изменились по поверхностному сравнению
useMemoКеширует результат вычисления
useCallbackСохраняет ссылку на функцию между рендерами
Разделение контекстовЧасто меняющееся значение не тянет за собой всех потребителей
Ключи в спискахСтабильный key позволяет переиспользовать узлы, а не пересоздавать
Почему React.memo часто не срабатывает. Сравнение поверхностное, а объекты и функции создаются заново на каждом рендере родителя. Передали style={{ margin: 8 }} или инлайновый обработчик — и memo бесполезен: ссылка каждый раз новая. Поэтому memo работает в паре с useCallback и useMemo, а не сам по себе.
Сначала мерить, потом мемоизировать. Мемоизация не бесплатна: хранение значения, сравнение зависимостей, усложнение кода. Профилировать в React Profiler и оптимизировать найденное — дешевле, чем обвешивать useMemo всё подряд.
Перерисовка — это не обновление DOM

Главное заблуждение при оптимизации. «Компонент перерисовался» означает лишь, что React вызвал функцию компонента и сравнил результат с прошлым. Если разметка не изменилась, в DOM не уйдёт ни одной правки — а именно работа с DOM и стоит дорого.

setState │ ├─ render-фаза: вызов функций компонентов, сравнение деревьев │ дёшево — обычный JavaScript │ └─ commit-фаза: применение правок к DOM дорого — пересчёт стилей, перерасклад, отрисовка

Поэтому сотня «лишних» вызовов простых компонентов обычно не влияет ни на что. Проблемой перерисовки становятся в трёх случаях:

СлучайПочему больно
Длинные спискиТысяча строк пересобирается при каждом нажатии клавиши в фильтре
Тяжёлый расчёт при рендереСортировка или разбор данных повторяется без надобности
Частые событияДвижение мыши, скролл, ввод текста — десятки рендеров в секунду
Порядок действий при тормозах. Сначала измерить профайлером, какой компонент занимает время. Потом проверить, нет ли дорогого расчёта в теле компонента. И только потом тянуться к memo. Мемоизация сама по себе не бесплатна: сравнение props и хранение кэша тоже стоят времени и памяти.
Как найти настоящую причину
ИнструментЧто показывает
React DevTools → вкладка ProfilerЗапись сеанса: какие компоненты рендерились, сколько заняли и почему
Настройка «Highlight updates»Подсвечивает перерисовываемые области прямо на странице — сразу видно лишнее
Вкладка Performance в браузереРазделяет время на скрипт, пересчёт стилей и отрисовку — видно, где именно узкое место

Типичная находка: тормозит не тот компонент, который перерисовывается часто, а тот, что делает тяжёлую работу в теле.

// Сортировка на каждом рендере, даже когда менялся только фильтр
function Table({ rows, query }) {
  const sorted = [...rows].sort(byDate);        // 10 000 элементов
  const shown  = sorted.filter(r => r.name.includes(query));
  return <List items={shown} />;
}

// Дорогое закэшировано по своим зависимостям, дешёвое считается всегда
function Table({ rows, query }) {
  const sorted = useMemo(() => [...rows].sort(byDate), [rows]);
  const shown  = sorted.filter(r => r.name.includes(query));
  return <List items={shown} />;
}
Мемоизация всего подряд. Обернуть каждую функцию в useCallback, а каждое значение в useMemo — распространённая ошибка. Код становится труднее читать, зависимостей больше, а выигрыша нет: для дешёвых значений сравнение стоит дороже пересчёта.
Компилятор меняет правила. React Compiler расставляет мемоизацию автоматически. Там, где он подключён, ручные memo, useMemo и useCallback в большинстве случаев становятся не нужны — но проверять выигрыш всё равно профайлером.
Где хранить состояние
Вид состоянияЧем держатьПримеры
ЛокальноеuseState, useReducerОткрыт ли дропдаун, значение поля ввода
СерверноеTanStack Query, RTK QueryДанные с API: кеш, ретраи, инвалидация
Глобальное клиентскоеZustand, Redux Toolkit, ContextТекущий пользователь, тема, выбранный чат
Ключевое различие. Серверное состояние — это кеш чужих данных: оно устаревает, его надо перезапрашивать и инвалидировать. Класть его в Redux руками означает переписать половину TanStack Query самому.
Когда Context — плохая идея. Любое изменение значения контекста перерисовывает всех потребителей. Для часто меняющихся данных это дорого. Context хорош для редко меняющегося: тема, локаль, текущий пользователь.
Подъём состояния

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

// Фильтр и список должны видеть одну строку поиска
function OrdersPage() {
  const [query, setQuery] = useState('');   // состояние поднято сюда
  return (
    <>
      <SearchBox value={query} onChange={setQuery} />
      <OrderList query={query} />
    </>
  );
}

Если передавать значение приходится через четыре-пять этажей, это сигнал: либо нужен контекст, либо промежуточные компоненты стоит перестроить так, чтобы содержимое передавалось через children.

// Передача через children: Layout не знает ничего о данных,
// поэтому и props через него тянуть не нужно
<Layout>
  <OrderList query={query} />
</Layout>
Дублирование вместо подъёма. Копия значения в двух компонентах рано или поздно разойдётся: один обновился, другой нет. У каждого факта должен быть ровно один владелец.
Адресная строка как хранилище

Часть состояния правильнее держать не в памяти, а в URL. Признак простой: нужно ли, чтобы это пережило перезагрузку страницы и чтобы ссылкой можно было поделиться.

Хранить в URLХранить в памяти
Фильтры и поиск в таблицеОткрыт ли выпадающий список
Номер страницы и сортировкаТекст, который ещё печатают
Активная вкладка разделаПозиция прокрутки
Идентификатор открытой карточкиНаведение мыши
// react-router: параметры запроса ведут себя как состояние
const [params, setParams] = useSearchParams();
const status = params.get('status') ?? 'all';

setParams(prev => {
  prev.set('status', 'paid');
  return prev;
});
// Теперь кнопка «назад» работает ожидаемо, а ссылку можно отправить коллеге
Привычное сравнение. Это ровно та же логика, что и с GET-параметрами на сервере: состояние списка задаётся запросом, а не сессией. В одностраничном приложении легко про это забыть и спрятать фильтры в память — после чего ссылка на «отфильтрованный список» перестаёт существовать.
Когда нужно глобальное хранилище

Браться за Redux Toolkit или Zustand стоит, когда клиентское состояние действительно общее и меняется из разных мест: корзина, подключение к сокету, права текущего пользователя.

ContextZustandRedux Toolkit
Объём кодаМинимальныйНебольшойЗаметный
Подписка на часть состоянияНет, обновляются всеДа, по селекторуДа, по селектору
Инструменты отладкиНетЕстьПолная история действий
Где уместенТема, локаль, пользовательСреднее приложениеБольшая команда, сложные правила
Глобальное хранилище «на всякий случай». Состояние, которое используется одним экраном, в глобальном хранилище только мешает: его нужно чистить при уходе со страницы, иначе следующий заход покажет чужие данные.
Свои хуки

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

// Типизированные обёртки над Redux — чтобы не писать дженерики в каждом компоненте
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
// Хук с жизненным циклом: запускает поллинг и отменяет при размонтировании
export function useNotifications(enabled: boolean) {
  const dispatch = useAppDispatch();

  useEffect(() => {
    if (!enabled) return;
    const controller = new AbortController();
    poll({ signal: controller.signal, dispatch });
    return () => controller.abort();
  }, [enabled, dispatch]);
}
Два правила хуков: вызывать только на верхнем уровне компонента или другого хука (не в условиях и циклах) и только из React-функций. Имя обязано начинаться с use — по нему работает линтер.
Что выносить в хук, а что оставить

Хук оправдан, когда повторяется логика с состоянием или эффектами. Чистую функцию без хуков внутри выносить в хук не нужно — это просто функция.

В хукВ обычную функцию
Подписка и отписка от событияФорматирование даты или суммы
Загрузка данных с отменойСортировка и фильтрация массива
Работа с localStorage плюс состояниеВалидация строки
Таймеры и интервалы с очисткойПодсчёт итогов по списку
// Типовой хук: значение в localStorage, синхронизированное с состоянием
function useStored<T>(key: string, initial: T) {
  const [value, setValue] = useState<T>(() => {
    try {
      const raw = localStorage.getItem(key);
      return raw ? JSON.parse(raw) as T : initial;
    } catch {
      return initial;   // приватный режим или испорченное значение
    }
  });

  useEffect(() => {
    try { localStorage.setItem(key, JSON.stringify(value)); } catch {}
  }, [key, value]);

  return [value, setValue] as const;   // as const — чтобы вышел кортеж, а не массив
}
Хук не разделяет состояние. Два компонента, вызвавшие один и тот же хук, получат независимые копии состояния. Хук переиспользует логику, а не данные. Общие данные — это контекст или хранилище.
Кортеж или объект

Небольшая деталь, заметно влияющая на удобство: что именно хук возвращает.

// Кортеж — когда значений два и имена даёт вызывающий
const [isOpen, setOpen] = useToggle(false);
const [isDark, setDark] = useToggle(true);   // переименовать легко

// Объект — когда значений много и часть не нужна
const { data, isPending, refetch } = useOrders(userId);
// порядок не важен, лишнее можно не доставать
Правило. Два значения — кортеж, больше трёх — объект. Именно поэтому useState возвращает пару, а библиотеки запросов — объект с десятком полей.
Списки, ключи и формы
Зачем нужен key

При обновлении списка React сопоставляет старые и новые элементы. Без key сопоставление идёт по порядковому номеру, и если элементы переставили, вставили или удалили, React решит, что изменилось содержимое каждой позиции, а не порядок.

Было: [ Анна ] [ Борис ] [ Вера ] Стало: [ Борис ] [ Вера ] ← удалили Анну Без key — сопоставление по позиции: позиция 0: Анна → Борис «текст изменился» позиция 1: Борис → Вера «текст изменился» позиция 2: Вера → нет «удалить» Итог: переписаны все узлы; состояние внутри строк уехало на соседей. С key — сопоставление по идентификатору: key=2 Борис → остался на месте key=3 Вера → остался на месте key=1 Анна → удалён Итог: удалён один узел, остальные не тронуты.
// Индекс как key — ломается при вставке, удалении и сортировке
{users.map((u, i) => <UserRow key={i} user={u} />)}

// Стабильный идентификатор из данных
{users.map(u => <UserRow key={u.id} user={u} />)}
Самый заметный симптом. В списке с полями ввода и key={i} удалите первую строку — введённый текст «съедет» на соседнюю строку. Состояние осталось привязано к позиции, а не к записи.
Индекс допустим, только если список неизменяемый: не сортируется, не фильтруется, элементы не добавляются и не удаляются. Такие списки встречаются редко.
key уникален среди соседей, а не глобально. Два разных списка на странице могут содержать одинаковые ключи — это нормально. И key не попадает в props: прочитать его внутри компонента нельзя, нужно передать отдельным атрибутом.
Управляемые и неуправляемые поля

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

УправляемоеНеуправляемое
Источник истиныСостояние ReactСам DOM-узел
Как пишетсяvalue + onChangedefaultValue + ref
ПерерисовкаНа каждое нажатие клавишиНе происходит
Когда удобноВалидация на лету, зависимые поля, форматирование вводаБольшие формы, файлы, интеграция со сторонним кодом
// Управляемое: React знает значение в любой момент
const [email, setEmail] = useState('');
<input value={email} onChange={e => setEmail(e.target.value)} />

// Неуправляемое: значение читаем в момент отправки
const emailRef = useRef(null);
<input defaultValue="" ref={emailRef} />
// ... emailRef.current.value
value без onChange. Поле станет доступным только для чтения, а в консоли появится предупреждение. Если значение задаётся один раз — нужен defaultValue.
undefined как начальное значение. useState() без аргумента делает поле неуправляемым, а первый ввод переключает его в управляемое. React предупреждает об этом в консоли. Начинайте с пустой строки.
Отправка формы

Браузер по умолчанию перезагружает страницу при отправке. Это нужно отменить и обязательно блокировать повторную отправку, пока запрос в пути.

function LoginForm() {
  const [form, setForm] = useState({ email: '', password: '' });
  const [sending, setSending] = useState(false);
  const [error, setError] = useState(null);

  // одна функция на все поля — по атрибуту name
  const change = e =>
    setForm(f => ({ ...f, [e.target.name]: e.target.value }));

  async function submit(e) {
    e.preventDefault();        // без этого страница перезагрузится
    if (sending) return;       // защита от двойного клика
    setSending(true); setError(null);
    try {
      await login(form);
    } catch (err) {
      setError(err.message);
    } finally {
      setSending(false);     // finally — иначе форма зависнет после ошибки
    }
  }

  return (
    <form onSubmit={submit}>
      <input name="email" value={form.email} onChange={change} />
      <input name="password" type="password" value={form.password} onChange={change} />
      {error && <p role="alert">{error}</p>}
      <button disabled={sending}>{sending ? 'Входим…' : 'Войти'}</button>
    </form>
  );
}
Мутация состояния. form.email = v; setForm(form) не вызовет перерисовку: ссылка на объект не изменилась, а React сравнивает именно ссылки. Всегда создавайте новый объект через расширение.
Сброс sending без finally. Если сбрасывать флаг только в удачной ветке, после первой же ошибки кнопка останется заблокированной навсегда.
Контекст, useReducer и useRef
Контекст: против передачи props через этажи

Когда значение нужно глубоко внизу дерева, его приходится передавать через все промежуточные компоненты, которым оно не нужно (это называют prop drilling). Контекст позволяет положить значение наверху и прочитать внизу напрямую.

// 1. Создаём контекст со значением по умолчанию
const ThemeContext = createContext('light');

// 2. Отдаём значение поддереву
function App() {
  const [theme, setTheme] = useState('light');
  // объект создаётся заново на каждом рендере — мемоизируем,
  // иначе все потребители перерисуются даже без реальных изменений
  const value = useMemo(() => ({ theme, setTheme }), [theme]);
  return <ThemeContext.Provider value={value}><Layout /></ThemeContext.Provider>;
}

// 3. Читаем на любой глубине
function ThemeToggle() {
  const { theme, setTheme } = useContext(ThemeContext);
  return <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>{theme}</button>;
}
Контекст — не замена состоянию приложения. Любое изменение значения перерисовывает всех потребителей, даже тех, кому нужна лишь часть объекта. Для часто меняющихся данных (позиция курсора, ввод в поле) это заметно. Решения: разделить на несколько контекстов или взять внешнее хранилище.
Новый объект в value. value={{ user, logout }} создаёт новую ссылку на каждом рендере родителя — потребители обновятся всегда. Отсюда useMemo в примере.
useReducer: когда useState перестаёт помогать

Признак, что пора переходить: несколько useState меняются вместе, а правила перехода между состояниями размазаны по обработчикам. Редьюсер собирает эти правила в одном месте и делает невозможные комбинации невыразимыми.

// Было: три состояния, которые нужно держать согласованными вручную
const [loading, setLoading] = useState(false);
const [data, setData]       = useState(null);
const [error, setError]     = useState(null);
// ничто не мешает получить loading: true и error: 'сбой' одновременно

// Стало: одно состояние, переходы описаны явно
function reducer(state, action) {
  switch (action.type) {
    case 'load':    return { status: 'loading' };
    case 'success': return { status: 'ready', data: action.payload };
    case 'failure': return { status: 'error', message: action.error };
    default:        return state;
  }
}

const [state, dispatch] = useReducer(reducer, { status: 'idle' });
// dispatch({ type: 'load' }) — компонент не знает, как устроен переход
Берите useStateБерите useReducer
Одно-два независимых значенияНесколько значений меняются согласованно
Переход очевиден из места вызоваПереходы образуют конечный автомат
Логика помещается в обработчикЛогику хочется покрыть тестами отдельно от компонента
Редьюсер — чистая функция. Никаких запросов, таймеров и Math.random() внутри. Те же аргументы — тот же результат. Это делает его тестируемым без React и предсказуемым в строгом режиме, который вызывает редьюсер дважды.
useRef: значение, которое переживает рендер

У useRef две разные работы, и обе сводятся к одному: это изменяемая ячейка, которая не вызывает перерисовку.

// 1. Доступ к узлу DOM
const inputRef = useRef(null);
useEffect(() => { inputRef.current?.focus(); }, []);
<input ref={inputRef} />

// 2. Хранилище между рендерами: таймеры, предыдущее значение, флаги
const timerRef = useRef(null);
function start() {
  timerRef.current = setInterval(tick, 1000);
}
useEffect(() => () => clearInterval(timerRef.current), []); // очистка
useStateuseRef
Изменение вызывает рендерДаНет
Значение между рендерамиСохраняетсяСохраняется
Чтение во время рендераМожноНежелательно
Для чегоДанные, влияющие на разметкуВсё, что на разметку не влияет
Ref не для данных на экране. Записали в ref.current и ждёте обновления интерфейса — его не будет, React о записи не знает. Если значение видно пользователю, это состояние.
Загрузка данных и состояния экрана
Четыре состояния, а не одно

Самая частая ошибка новичка — рисовать только удачный случай. У любого экрана с запросом к серверу состояний как минимум четыре, и каждое должно быть нарисовано.

СостояниеЧто видит пользовательЧто забывают
ЗагрузкаСкелетон или индикаторПрыжок вёрстки, когда данные пришли
ОшибкаТекст и кнопка «Повторить»Экран остаётся пустым навсегда
Пусто«Пока ничего нет» и подсказкаВыглядит как поломка
ДанныеСобственно содержимое—
if (status === 'loading') return <Skeleton rows={5} />;
if (status === 'error')   return <ErrorBox message={message} onRetry={reload} />;
if (!items.length)         return <Empty hint="Добавьте первый заказ" />;
return <OrderList items={items} />;
Запрос в useEffect: полный вариант

Если писать загрузку руками, необходимы две вещи: отмена устаревшего запроса и защита от записи в размонтированный компонент.

function useOrders(userId) {
  const [state, setState] = useState({ status: 'loading' });

  useEffect(() => {
    const ac = new AbortController();
    setState({ status: 'loading' });

    fetch(`/api/users/${userId}/orders`, { signal: ac.signal })
      .then(r => {
        // fetch не считает 404 и 500 ошибкой — проверяем сами
        if (!r.ok) throw new Error(`HTTP ${r.status}`);
        return r.json();
      })
      .then(data => setState({ status: 'ready', data }))
      .catch(err => {
        if (err.name === 'AbortError') return;  // это мы сами отменили
        setState({ status: 'error', message: err.message });
      });

    return () => ac.abort();   // userId сменился или компонент ушёл
  }, [userId]);

  return state;
}
Гонка ответов. Без отмены быстрое переключение между пользователями приведёт к тому, что медленный ответ по старому userId придёт последним и перетрёт правильные данные. Экран покажет чужие заказы.
fetch не бросает на 4xx и 5xx. Промис отклоняется только при сетевом сбое. Без проверки r.ok страница HTML с ошибкой уедет в r.json() и упадёт уже там, с непонятным сообщением про синтаксис.
async прямо в useEffect. useEffect(async () => …) вернёт промис вместо функции очистки, и React предупредит об этом. Объявляйте асинхронную функцию внутри и вызывайте её.
Почему в проектах берут библиотеку

Приведённый хук решает один экран. В приложении появляются кэш, повторные попытки, обновление в фоне, инвалидация после изменения данных — и это уже отдельная задача. Её закрывают TanStack Query (React Query) или RTK Query.

// То же самое через TanStack Query
const { data, isPending, isError, refetch } = useQuery({
  queryKey: ['orders', userId],   // ключ кэша: сменился userId — новый запрос
  queryFn: ({ signal }) => fetch(`/api/users/${userId}/orders`, { signal })
    .then(r => r.ok ? r.json() : Promise.reject(new Error(r.status))),
  staleTime: 30_000,            // 30 секунд данные считаются свежими
});
Что даёт библиотекаЧто придётся писать руками
Кэш по ключу и дедупликация одинаковых запросовСвой словарь запросов и проверка «уже грузим»
Повтор при сбое и обновление при возврате на вкладкуСчётчик попыток, подписка на visibilitychange
Инвалидация после отправки формыРучной перезапрос во всех местах, где показаны данные
Единое состояние загрузки для всего приложенияСвой флаг в каждом компоненте
Разделение, которое стоит усвоить. Состояние сервера (пришло по сети, может устареть, принадлежит не вам) и состояние интерфейса (открыт ли модал, какая вкладка активна) — разные вещи. Первое удобно отдать библиотеке запросов, второе оставить в useState. Попытка хранить и то и другое в одном глобальном хранилище — источник большей части сложности в старых проектах на Redux.
Чтобы одна ошибка не гасила страницу

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

<ErrorBoundary fallback={<p>Виджет временно недоступен</p>}>
  <RevenueChart />
</ErrorBoundary>
Что граница не ловит: ошибки в обработчиках событий, в асинхронном коде, при серверном рендеринге и внутри самой границы. Обработчик клика нужно оборачивать в try/catch самостоятельно.
tsconfig и строгий режим
Что вообще делает компилятор

TypeScript не выполняется. Он проверяет типы и стирает их, оставляя обычный JavaScript. Отсюда главное следствие: ни одна аннотация не существует во время работы программы — проверить тип данных, пришедших с сервера, типами нельзя.

// Исходник на TypeScript
interface User { id: number; name: string; }
function greet(u: User): string { return `Привет, ${u.name}`; }

// Что останется после компиляции — интерфейс исчез целиком
function greet(u) { return `Привет, ${u.name}`; }
Разделение, которое снимает большинство вопросов. Типы живут во время сборки, данные — во время работы. Всё, что приходит извне (ответ API, параметр адреса, содержимое localStorage), нужно проверять настоящим кодом. Подробнее — в разделе Типы против валидации.
strict: ради чего всё затевалось

"strict": true включает набор проверок разом. Без него TypeScript теряет большую часть пользы: null проходит всюду, а необъявленные типы молча становятся any.

Флаг внутри strictЧто ловит
strictNullChecksГлавный. Без него null и undefined допустимы в любом типе, и обращение к полю отсутствующего объекта не будет ошибкой
noImplicitAnyПараметр без аннотации, тип которого вывести не удалось
strictFunctionTypesНесовместимую подстановку функции вместо другой функции
strictPropertyInitializationПоле класса, которое объявлено, но никогда не присваивается
useUnknownInCatchVariablesПеременная в catch получает unknown, а не any — исключение придётся проверить

Полезны и те, что в strict не входят:

ФлагЗачем
noUncheckedIndexedAccessДоступ по индексу даёт T | undefined. Снимает целый класс ошибок с массивами и словарями
noImplicitOverrideПереопределение метода родителя требует слова override
exactOptionalPropertyTypesРазличает «поля нет» и «поле равно undefined»
verbatimModuleSyntaxТребует import type для импорта только типов — важно для корректной сборки
// Чем помогает noUncheckedIndexedAccess
const ids: number[] = [];
const first = ids[0];
// без флага: number  → first.toFixed() компилируется и падает в рантайме
// с флагом:  number | undefined → компилятор требует проверку
if (first !== undefined) first.toFixed();
Перевод старого проекта. Включать strict разом на большой кодовой базе — тысячи ошибок и соблазн всё отключить. Включают по одному флагу, начиная с noImplicitAny, либо держат строгость только для новых файлов.
any, unknown и never
ТипСмыслКогда уместен
anyПроверки отключены. Любая операция разрешенаПочти никогда. Точечно при переводе старого кода
unknownЗначение есть, но тип неизвестен. Ничего делать нельзя, пока не проверитеОтвет сети, JSON.parse, переменная в catch
neverЗначения не бывает в принципеФункция, которая всегда бросает; проверка полноты switch
// any заражает всё вокруг — ошибка всплывёт далеко от причины
const data: any = JSON.parse(raw);
data.user.profile.name.trim();   // компилируется; падает в рантайме

// unknown заставляет проверить
const data: unknown = JSON.parse(raw);
if (typeof data === 'object' && data !== null && 'user' in data) {
  // здесь TypeScript уже знает, что поле есть
}

// never ловит забытую ветку при добавлении нового варианта
function assertNever(x: never): never { throw new Error('Необработанный вариант: ' + x); }
Утверждение типа — не проверка. value as User ничего не проверяет, а лишь приказывает компилятору замолчать. Если данные другие, ошибка просто переедет дальше по коду и станет труднее для поиска.
TypeScript: interface, type, дженерики
interface против type
interfacetype
РасширениеextendsПересечение &
Слияние объявленийДа — два одноимённых объединяютсяНет, будет ошибка
UnionНетДа — 'a' | 'b'
Примитивы, кортежиНетДа
Практическое правило: interface для формы объекта, который могут расширять; type для union, кортежей и вычисляемых типов. Слияние объявлений у interface — то, чем пользуются библиотеки, чтобы дать тебе дописать их типы.
Дженерики
// T выводится из аргумента — возвращаемый тип совпадёт с типом элементов
function first<T>(arr: T[]): T | undefined {
  return arr[0];
}

first([1, 2, 3]);        // number | undefined
first(['a', 'b']);       // string | undefined

// Ограничение: принимать только то, у чего есть id
function byId<T extends { id: string }>(items: T[]): Record<string, T> {
  return Object.fromEntries(items.map(i => [i.id, i]));
}
Вывод типов: писать меньше, проверять столько же

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

// Избыточно — тип и так ясен
const name: string = 'Иван';

// Нужно: аргументы функции вывести неоткуда
function total(items: Item[], rate: number) { … }

// Нужно: фиксируем возвращаемый тип, чтобы случайная правка
// внутри функции не изменила её контракт незаметно
function toDto(u: User): UserDto { … }

// Разница, о которую спотыкаются: let расширяет литеральный тип
let status = 'idle';        // string
const status2 = 'idle';      // 'idle' — литеральный тип

// as const фиксирует всю структуру целиком
const config = { mode: 'dark', retries: 3 } as const;
// { readonly mode: 'dark'; readonly retries: 3 }
Объект, переданный в функцию. const opts = { mode: 'dark' } имеет тип { mode: string }, и передать его туда, где ждут { mode: 'dark' | 'light' }, не получится. Лечится через as const или аннотацией переменной.
Ограничения дженериков

Без ограничения параметр типа — это «что угодно», и внутри функции с ним ничего нельзя сделать. extends сужает допустимые типы, сохраняя при этом конкретику на выходе.

// Без ограничения: обратиться к .length нельзя — вдруг это число
function first<T>(x: T) { return x.length; }   // ошибка

// С ограничением: требуем наличие length
function first<T extends { length: number }>(x: T) { return x.length; }

// keyof — ключ точно существует у объекта, а тип значения выводится
function pluck<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const user = { id: 1, name: 'Иван' };
const id = pluck(user, 'id');       // number
const no = pluck(user, 'email');    // ошибка компиляции: такого ключа нет
Зачем это на практике. Так типизируют функции работы с данными: sortBy(rows, 'createdAt') проверит, что поле существует, и сам выведет его тип. Без ограничений пришлось бы писать any и потерять проверку.
TypeScript: утилитарные типы
ТипЧто делаетПример применения
Partial<T>Все поля необязательныеDTO для PATCH-запроса
Required<T>Все поля обязательныеКонфиг после применения дефолтов
Pick<T, K>Взять перечисленные поляПубличный профиль из модели пользователя
Omit<T, K>Убрать перечисленныеМодель без password и id
Record<K, V>Словарь с заданными ключамиНормализованный стор: Record<string, Chat>
ReturnType<F>Тип возврата функцииRootState = ReturnType<typeof store.getState>
Awaited<T>Тип внутри промисаРезультат асинхронной функции
Зачем это нужно на практике. Утилитарные типы позволяют держать один источник правды. Описал модель один раз — а DTO на создание, на обновление и публичный ответ выводятся из неё через Omit и Partial. Иначе три похожих интерфейса разъезжаются при первой же правке.
Как они устроены внутри

Утилитарные типы — не магия компилятора, а обычные обобщённые типы, написанные на том же языке типов. Понимание их устройства позволяет писать свои.

// Так объявлены Partial и Readonly в стандартной библиотеке
type Partial<T>  = { [K in keyof T]?: T[K] };
type Readonly<T> = { readonly [K in keyof T]: T[K] };

// Pick перебирает только указанные ключи
type Pick<T, K extends keyof T> = { [P in K]: T[P] };

// Условный тип: ветвление на уровне типов
type Unwrap<T> = T extends Promise<infer U> ? U : T;
type A = Unwrap<Promise<User>>;   // User
type B = Unwrap<number>;            // number
Свои типы под задачи проекта
// Сделать часть полей обязательными, остальные оставить как есть
type RequireFields<T, K extends keyof T> = T & Required<Pick<T, K>>;

// Черновик заказа, у которого точно есть идентификатор клиента
type DraftOrder = RequireFields<Partial<Order>, 'customerId'>;

// Глубокая неизменяемость — для конфигов и замороженных данных
type DeepReadonly<T> = {
  readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};
Увлечение типами. Многоэтажная конструкция из условных типов решает задачу, но через полгода её не прочитает никто, включая автора. Если тип занял десяток строк — чаще всего стоит упростить структуру данных, а не изощряться в типах.
TypeScript: сужение типов
Три способа
// 1. typeof — для примитивов
if (typeof value === 'string') { value.toUpperCase(); }

// 2. in — по наличию поля
if ('error' in result) { report(result.error); }

// 3. Discriminated union — поле-дискриминатор
type Result =
  | { status: 'ok';    data: User }
  | { status: 'error'; message: string };

if (res.status === 'ok') {
  res.data;      // TypeScript знает: здесь есть data
} else {
  res.message;   // а здесь message
}
Discriminated union — главный инструмент. Один литеральный ключ (status, kind, type) разводит ветки, и компилятор сам понимает, какие поля доступны в каждой. Это же позволяет проверять полноту switch через never.
unknown вместо any
const raw: any = JSON.parse(body);
raw.user.name.toUpperCase();     // компилируется, падает в рантайме

const raw: unknown = JSON.parse(body);
raw.user;                          // ошибка компиляции — заставит проверить
any отключает проверки молча и заразно. Любое выражение с any тоже становится any, и дыра расползается по коду. unknown безопасен: с ним ничего нельзя сделать, пока не сузишь тип.
Предикаты типов: научить компилятор своей проверке

Встроенные typeof и in покрывают не всё. Для собственных проверок есть предикат — функция, чей возвращаемый тип записан как arg is Type.

// Обычная функция: вернула true, но компилятор ничего нового не узнал
function isUser(x: unknown): boolean { … }

// Предикат: после проверки тип сужается
function isUser(x: unknown): x is User {
  return typeof x === 'object' && x !== null
    && 'id' in x && typeof (x as User).id === 'number';
}

if (isUser(data)) {
  data.id;   // здесь это User
}

// Частый случай: отфильтровать пустые значения из массива
const clean = list.filter((x): x is string => x != null);
// без предиката тип остался бы (string | null)[]
Предикат — обещание под вашу ответственность. Компилятор не проверяет, что тело функции действительно устанавливает заявленное. Неполная проверка внутри означает неверное сужение снаружи — и ошибка проявится далеко от этого места.
Где сужение теряется

Сужение живёт до тех пор, пока компилятор уверен, что значение не могло измениться. Есть несколько мест, где эта уверенность пропадает.

interface Box { value?: string }

function bad(box: Box) {
  if (box.value) {
    setTimeout(() => box.value.trim(), 0);
    // ошибка: между проверкой и вызовом поле могли обнулить
  }
}

function good(box: Box) {
  const value = box.value;          // копия в константу
  if (value) {
    setTimeout(() => value.trim(), 0);   // сужение сохраняется
  }
}
Где теряетсяПочему
Внутри колбэка или замыканияКод выполнится позже, поле могли изменить
После вызова любой функцииКомпилятор считает, что она могла поменять объект
У свойства изменяемого объектаНадёжно сужаются локальные константы, а не поля
После awaitЗа время ожидания состояние могло измениться
Размеченные объединения
Сделать невозможное состояние невыразимым

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

// Четыре поля дают 16 комбинаций, осмысленных — три
interface State {
  loading: boolean;
  data?: Order[];
  error?: string;
}
// { loading: true, error: 'сбой', data: [...] } — тип это допускает

// Размеченное объединение: поле-метка status различает варианты
type State =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'ready'; data: Order[] }
  | { status: 'error'; message: string };

Теперь компилятор сам проводит по веткам и в каждой знает, какие поля доступны:

function render(state: State) {
  switch (state.status) {
    case 'idle':
    case 'loading': return <Skeleton />;
    case 'ready':   return <List items={state.data} />;      // data точно есть
    case 'error':   return <Error text={state.message} />;   // message точно есть
  }
}
// state.data в ветке 'error' — ошибка компиляции, а не падение у пользователя
Признак, что приём нужен. В коде встречается data! с восклицательным знаком или as — значит, вы знаете о данных больше, чем тип. Почти всегда это означает, что тип описан через необязательные поля там, где нужно объединение.
Проверка полноты

Через год в объединение добавят новый вариант. Нужно, чтобы компилятор показал все места, которые его не обрабатывают, — иначе ошибка найдётся на проде.

type Payment =
  | { kind: 'card'; last4: string }
  | { kind: 'cash' }
  | { kind: 'crypto'; chain: string };   // ← добавили позже

function label(p: Payment): string {
  switch (p.kind) {
    case 'card': return `Карта •••• ${p.last4}`;
    case 'cash': return 'Наличные';
    default: {
      // если какой-то вариант не обработан, он «дотечёт» сюда
      // и не подойдёт под never — будет ошибка компиляции
      const rest: never = p;
      throw new Error(`Неизвестный способ оплаты: ${JSON.stringify(rest)}`);
    }
  }
}
// Ошибка: Type '{ kind: "crypto"; chain: string; }' is not assignable to type 'never'
Метка должна быть литералом. Если поле объявлено как kind: string, сужение не работает — TypeScript не может различить варианты. Нужны именно строковые литералы.
Объединение вместо enum

В TypeScript есть enum, но в современном коде чаще берут объединение строковых литералов: оно не оставляет следа после компиляции, проще сериализуется и лучше читается в отладчике.

enumОбъединение литералов
Код после сборкиСоздаёт объект в бандлеИсчезает полностью
Значение в отладчикеЧисло, если не строковый enumЧитаемая строка
Сравнение с данными APIНужно приводитьНапрямую
Перебор вариантовВстроенныйЧерез массив as const
const ROLES = ['admin', 'editor', 'viewer'] as const;
type Role = typeof ROLES[number];   // 'admin' | 'editor' | 'viewer'

// Массив можно перебирать, тип выводится из него — источник один
ROLES.forEach(r => console.log(r));
const enum ведёт себя иначе при раздельной сборке модулей и ломается с isolatedModules, который включён в сборщиках на основе esbuild (Vite). В новом коде его лучше не использовать.
TypeScript: типы против валидации
Граница компиляции и рантайма
Типы исчезают при компиляции. В собранном JS их нет вообще. Тип не защищает от данных, которые пришли снаружи: от клиента, из API, из localStorage. Объявление const user: User = await res.json() — это обещание, а не проверка.
ТипыВалидация
Когда работаетКомпиляцияРантайм
От чего защищаетОт ошибок в своём кодеОт неверных внешних данных
Инструментыtsczod, class-validator, DTO
// zod: схема одновременно проверяет и ВЫВОДИТ тип
const UserSchema = z.object({
  id:    z.string().uuid(),
  email: z.string().email(),
  age:   z.number().int().min(0),
});

type User = z.infer<typeof UserSchema>;   // тип из схемы, не дублируем

const user = UserSchema.parse(await res.json());  // бросит, если данные не те
На бэкенде ту же роль играют DTO с class-validator: тип описывает форму, декораторы проверяют значение в рантайме. Одно без другого неполно.
Где проходит граница проверки

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

внешний мир граница внутренности приложения ───────────── ───────── ─────────────────────── ответ API → схема (zod) → типизированные данные формы → схема → команда или запрос localStorage → схема → состояние параметры URL → схема → фильтры За границей — unknown. Внутри — типы, которым можно верить.
Проверять обязательноПроверять не нужно
Ответы внешних сервисовДанные, которые вы только что сами и составили
Тело запроса от клиентаВнутренние вызовы между своими функциями
localStorage и cookieКонстанты в коде
Параметры адресаРезультат уже провалидированной схемы
Знакомая аналогия. Это та же роль, что у FormRequest в Laravel: до него — сырые данные запроса, после — проверенные. Разница лишь в том, что на фронтенде такую границу приходится ставить самому, и чаще всего её ставят вокруг слоя запросов к API.
Один источник истины для типа и проверки

Описывать форму данных дважды — отдельно интерфейс и отдельно проверку — верный способ получить расхождение. Схема решает обе задачи сразу: проверяет и выводит тип.

import { z } from 'zod';

const OrderSchema = z.object({
  id: z.number().int().positive(),
  status: z.enum(['new', 'paid', 'shipped']),
  total: z.number().nonnegative(),
  comment: z.string().optional(),
});

// Тип выводится из схемы — второго описания не существует
type Order = z.infer<typeof OrderSchema>;

async function loadOrder(id: number): Promise<Order> {
  const res = await fetch(`/api/orders/${id}`);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);

  // safeParse не бросает — удобно показать понятную ошибку
  const parsed = OrderSchema.safeParse(await res.json());
  if (!parsed.success) {
    report(parsed.error);                  // бэкенд изменил контракт
    throw new Error('Неверный формат ответа');
  }
  return parsed.data;                      // здесь данным уже можно верить
}
Молчаливое падение без схемы. const o = await res.json() as Order компилируется всегда. Если сервер переименовал поле, ошибка вылезет в компоненте при отрисовке — в месте, по которому причину уже не найти.
TypeScript в React
// Пропсы компонента
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: 'primary' | 'ghost';
  loading?: boolean;
}

export function Button({ variant = 'primary', loading, ...rest }: ButtonProps) {
  return <button disabled={loading} {...rest} />;
}
// Состояние: явный тип нужен, когда начальное значение не описывает форму
const [user, setUser] = useState<User | null>(null);
const [items, setItems] = useState<Item[]>([]);

// Ссылка на DOM-узел
const inputRef = useRef<HTMLInputElement>(null);

// Событие
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => setText(e.target.value);
Расширение нативных атрибутов (ButtonHTMLAttributes) даёт компоненту все стандартные пропсы — onClick, disabled, type — без ручного перечисления.
События и ссылки на узлы

Типы событий React именованы по виду события и типу элемента. Проще всего не писать их вручную, а дать вывести из места использования.

// Обработчик объявлен прямо в разметке — тип выводится сам
<input onChange={e => setValue(e.target.value)} />

// Вынесли в отдельную функцию — тип нужно указать
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
  setValue(e.target.value);
}

function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
  e.preventDefault();
}

// Ссылки на узлы: начальное значение null, отсюда и проверка перед обращением
const inputRef = useRef<HTMLInputElement>(null);
inputRef.current?.focus();
Что типизируемТип
Содержимое компонентаReact.ReactNode
Обработчик без параметров() => void
Клик по кнопкеReact.MouseEvent<HTMLButtonElement>
Все атрибуты элементаReact.ComponentProps<'button'>
React.FC. Этот тип долго был привычным, но он навязывает children даже компонентам, которые их не принимают, и мешает дженерикам. Сейчас рекомендуют обычную функцию с типизированным объектом props — как в примерах выше.
Дженерик-компоненты

Компонент списка, который должен работать с любыми данными и при этом сохранять их тип в колбэках, объявляется обобщённым.

interface ListProps<T> {
  items: T[];
  getKey: (item: T) => string | number;
  renderItem: (item: T) => React.ReactNode;
}

export function List<T>({ items, getKey, renderItem }: ListProps<T>) {
  return <ul>{items.map(i => <li key={getKey(i)}>{renderItem(i)}</li>)}</ul>;
}

// T выводится из items — внутри renderItem доступны поля Order
<List
  items={orders}
  getKey={o => o.id}
  renderItem={o => <b>{o.total}</b>}
/>
Что это даёт. Один компонент списка на всё приложение — и при этом полная проверка полей внутри каждого использования. Без дженерика пришлось бы писать any и потерять подсказки редактора ровно там, где они нужнее всего.
Токены, тема и тёмный режим
Откуда берутся классы

Tailwind не даёт готовых компонентов. Он берёт набор значений — цвета, отступы, размеры шрифта, радиусы — и генерирует из них классы. Этот набор называют дизайн-токенами, и именно его настраивают под проект.

/* Tailwind v4: конфигурация прямо в CSS, отдельный tailwind.config.js не нужен */
@import "tailwindcss";

@theme {
  /* имя токена определяет имя класса: --color-brand → bg-brand, text-brand */
  --color-brand: #ea2845;
  --color-surface: #fdfdfd;
  --font-display: "Manrope", sans-serif;
  --radius-card: 14px;
}
// Tailwind v3: привычный файл конфигурации
// extend добавляет к стандартной палитре, без него она заменяется целиком
export default {
  content: ['./src/**/*.{ts,tsx}'],
  theme: {
    extend: {
      colors: { brand: '#ea2845' },
      borderRadius: { card: '14px' },
    },
  },
};
Почему это важнее, чем кажется. Пока цвета и отступы берутся из токенов, интерфейс остаётся согласованным сам собой: нельзя случайно поставить «почти такой же» оттенок. Как только в разметке появляются произвольные значения вроде bg-[#e82746], это свойство теряется.
Тёмная тема

Вариант dark: применяет класс в тёмном режиме. Вопрос лишь в том, откуда берётся признак темноты.

СтратегияКак включаетсяКогда подходит
По системной настройкеПоведение по умолчанию, через медиазапросПереключателя в интерфейсе нет
По классу или атрибуту<html class="dark"> и своё правило вариантаПользователь выбирает тему сам
/* v4: объявляем, что тёмная тема включается атрибутом на html */
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
<div className="bg-white text-slate-900 dark:bg-slate-900 dark:text-slate-100">
  Карточка, которая умеет обе темы
</div>
Мигание при загрузке. Если тема читается из localStorage после загрузки React, страница сначала покажется светлой, а потом перекрасится. Лечится маленьким скриптом в <head>, который выставляет атрибут до первой отрисовки — ровно так сделано в этой базе знаний.
Удвоение классов. Писать dark: у каждого элемента утомительно и легко забыть. Надёжнее описать цвета переменными CSS, переопределить их для тёмной темы один раз и ссылаться на токены.
Почему класс не применился

Сборщик ищет имена классов текстом по исходникам. Он не выполняет код, поэтому собранное из кусков имя в сборку не попадёт — класс просто не будет существовать.

// Класса не окажется в CSS: такой строки в исходнике нет
const cls = `text-${color}-500`;

// Полные имена классов записаны явно
const COLORS = {
  danger: 'text-red-500',
  success: 'text-green-500',
} as const;
const cls = COLORS[kind];
СимптомОбычная причина
Класс не работает только в продеИмя собиралось из кусков; в разработке спасал полный набор классов
Не работает в новой папкеПуть не попал в content (v3) или не просматривается сборщиком (v4)
Классы из библиотеки не применяютсяЕё файлы лежат в node_modules и не просматриваются
Два класса конфликтуютПорядок в атрибуте не решает ничего — выигрывает тот, что идёт позже в CSS. Нужен tailwind-merge
Склейка классов в компонентах

Когда компонент принимает className снаружи, простая конкатенация даёт конфликт: в разметке будут оба класса, а применится не тот, который ожидали.

import { twMerge } from 'tailwind-merge';
import { clsx } from 'clsx';

// clsx — собирает классы по условиям
// twMerge — убирает конфликтующие, оставляя последний
export function Button({ variant = 'primary', className, ...rest }) {
  return (
    <button
      className={twMerge(clsx(
        'rounded-card px-4 py-2 font-semibold transition',
        variant === 'primary' && 'bg-brand text-white hover:opacity-90',
        variant === 'ghost'   && 'bg-transparent text-brand',
      ), className)}
      {...rest}
    />
  );
}

// <Button className="px-6" /> → останется px-6, а не оба px-4 и px-6
Для разработчика на бэкенде. Это та же задача, что и с конфликтом CSS-правил по специфичности, только решённая на уровне сборки строки классов, а не каскада. Класс в атрибуте — лишь имя; какое правило победит, решает порядок в итоговом файле CSS.
Tailwind: утилитарный подход
Классы вместо отдельных стилей

Вместо написания CSS для каждого блока — готовые утилитарные классы прямо в разметке. Один класс делает одну вещь.

<button class="px-4 py-2 rounded-lg bg-indigo-600 text-white font-medium
               hover:bg-indigo-700 disabled:opacity-50">
  Отправить
</button>
ПлюсМинус
Не нужно придумывать имена классовРазметка становится шумной
Стиль виден прямо в разметкеТрудно читать длинные строки классов
Нет мёртвого CSS — в сборку попадает только использованноеПовторы, если не выносить в компоненты
Нет конфликтов специфичностиПорог входа: надо выучить названия
Конфиг темы — источник правды. Цвета, отступы, шрифты и брейкпоинты задаются один раз в конфиге, а классы из него генерируются. Поэтому bg-brand-500 везде означает один и тот же цвет, и поменять его можно в одном месте.
Почему шкала, а не произвольные числа

Классы отступов и размеров берутся из шкалы, где шаг равен 0.25rem. Это выглядит ограничением, но именно оно даёт согласованный ритм: в интерфейсе не появятся отступы 13px, 14px и 15px рядом.

КлассЗначениеВ пикселях при корне 16px
p-10.25rem4px
p-20.5rem8px
p-41rem16px
p-61.5rem24px
p-[13px]произвольное13px — выпадает из шкалы

Произвольные значения в квадратных скобках существуют для редких случаев: подгонка под фон-картинку, чужой виджет, точный размер иконки. Если они встречаются часто — значение стоит добавить в тему как токен.

Произвольные значения вместо токенов. bg-[#ea2845] в двадцати местах означает, что смена фирменного цвета превратится в поиск по проекту. Токен bg-brand меняется в одном файле.
Сравнение с привычными подходами
Обычный CSS и БЭМCSS-модулиTailwind
Имена классовПридумываете самиПридумываете, уникальность автоматомНе придумываете вовсе
Риск конфликта стилейВысокий, каскад глобальныйНетНет
Мёртвый кодКопится незаметноКопитсяНевозможен: в сборку идёт использованное
Где смотреть стильВ другом файлеВ другом файлеПрямо в разметке
Чем платитеИменованием и каскадомПереключением между файламиДлинными строками классов
Суть выбора. Tailwind меняет проблему «придумать имя и не сломать каскад» на проблему «разметка стала шумной». Первая со временем усложняется, вторая решается выделением компонентов — поэтому в больших проектах обмен обычно считают выгодным.
Tailwind: адаптив и состояния
Префиксы
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
ГруппаПрефиксыСмысл
Адаптивsm: md: lg: xl:Применить от этой ширины и выше
Состоянияhover: focus: active: disabled:По состоянию элемента
Тёмная темаdark:При включённой тёмной теме
От родителяgroup-hover: peer-checked:Реакция на состояние соседа или родителя
Tailwind — mobile first. Класс без префикса действует на всех ширинах, а md: — от средней и выше. Поэтому базовые стили пишут для мобильного, а префиксами расширяют. Попытка сделать наоборот приводит к войне с каскадом.
Контейнерные запросы

Обычные префиксы смотрят на ширину окна. Но компоненту чаще важна ширина его места: одна и та же карточка в широкой колонке и в узкой боковой панели должна выглядеть по-разному, хотя окно одно.

<div className="@container">
  <!-- смотрит на ширину родителя с @container, а не окна -->
  <article className="flex flex-col @md:flex-row @md:items-center gap-4">
    <img className="w-full @md:w-32 rounded-lg" />
    <div>…</div>
  </article>
</div>
Когда что брать. Ширина окна — для общей раскладки страницы: количество колонок, показывать ли боковое меню. Ширина контейнера — для компонентов, которые переиспользуются в разных по размеру местах.
Реакция на состояние соседа и родителя

Два механизма закрывают случаи, ради которых раньше писали JavaScript: group — реакция на состояние родителя, peer — на состояние соседнего элемента.

<!-- group: стрелка реагирует на наведение всей карточки -->
<a className="group flex items-center gap-2 rounded-lg p-4 hover:bg-slate-50">
  <span>Открыть раздел</span>
  <Arrow className="transition group-hover:translate-x-1" />
</a>

<!-- peer: подсказка показывается, когда поле не прошло проверку -->
<input type="email" required className="peer border rounded px-3 py-2" />
<p className="hidden peer-invalid:block text-sm text-red-600">
  Введите корректный адрес
</p>
peer работает только вперёд. Селектор опирается на «следующего соседа», поэтому реагирующий элемент должен идти в разметке после того, за кем следит. Если расположить наоборот, стиль просто не применится.
Tailwind: как не утонуть в классах
Повторы выносятся в компоненты
Главное правило. Повторяющиеся наборы классов выносят в компоненты, а не в @apply повсюду. Компонент — естественная единица переиспользования; @apply возвращает ровно ту проблему именованных классов, от которой уходили.
// Вместо копирования классов по всему проекту — один компонент
export function Card({ children }: { children: React.ReactNode }) {
  return (
    <div className="rounded-xl border border-slate-200 bg-white p-5 shadow-sm">
      {children}
    </div>
  );
}
// Условные классы — через clsx, а не конкатенацию строк
<button className={clsx(
  'px-4 py-2 rounded-lg',
  variant === 'primary' && 'bg-indigo-600 text-white',
  variant === 'ghost'   && 'bg-transparent text-indigo-600',
  disabled && 'opacity-50 cursor-not-allowed',
)} />
Классы нельзя собирать из кусков строк. Запись `bg-${color}-500` не сработает: сборщик сканирует исходники по полным именам классов и такой класс просто не попадёт в CSS. Нужны полные имена в коде — в объекте-карте или в условиях.
Варианты компонента без лесенки условий

Когда у кнопки появляются вид, размер и состояние, условные выражения в clsx превращаются в кашу. Для этого есть class-variance-authority: варианты описываются таблицей.

import { cva, type VariantProps } from 'class-variance-authority';

const button = cva(
  'inline-flex items-center justify-center rounded-lg font-medium transition',
  {
    variants: {
      intent: {
        primary: 'bg-brand text-white hover:opacity-90',
        ghost:   'bg-transparent text-brand hover:bg-brand/10',
      },
      size: { sm: 'h-8 px-3 text-sm', md: 'h-10 px-4' },
    },
    defaultVariants: { intent: 'primary', size: 'md' },
  },
);

type Props = React.ComponentProps<'button'> & VariantProps<typeof button>;

export function Button({ intent, size, className, ...rest }: Props) {
  return <button className={twMerge(button({ intent, size }), className)} {...rest} />;
}
// Типы вариантов выводятся из описания — опечатка в intent не скомпилируется
Когда @apply всё-таки уместен

Общее правило — выносить повторы в компоненты. Но есть случаи, где компонента не существует и директива оправдана.

УместноНе уместно
Базовые стили документа: типографика, фокус, выделениеВоссоздание «своего» фреймворка из .btn и .card
Разметка из стороннего HTML, который нельзя правитьВсё, что можно оформить компонентом
Содержимое из Markdown или редактораСокращение «слишком длинной строки классов»
@layer base {
  /* оправданно: применяется ко всем ссылкам в статьях */
  .prose a { @apply text-brand underline underline-offset-2; }
}
Главная ловушка @apply. Создавая .btn из утилит, вы возвращаетесь ровно к тому, от чего уходили: имя нужно придумать, стиль снова в другом файле, а мёртвый CSS опять копится. Разница лишь в том, что теперь это написано утилитами.
Подводные камни
React
Устаревшее замыкание. Колбэк держит state старого рендера. Лечится честными зависимостями или setState(prev => ...).
useEffect без массива зависимостей. Запускается после каждой перерисовки — и часто сам же её и вызывает.
Гонка асинхронных запросов. Старый ответ перетирает новый. Нужна отмена в очистке эффекта.
key по индексу массива. При вставке в середину React переиспользует не те узлы — состояние элементов списка «съезжает».
Мемоизация без замеров. useMemo на всём подряд усложняет код и может быть дороже самого вычисления.
TypeScript
any как способ «починить» ошибку. Отключает проверки и расползается дальше по коду. Используй unknown и сужение.
Вера в типы на границе приложения. await res.json() возвращает что угодно. Внешние данные проверяются в рантайме.
Приведение через as вместо проверки. as User ничего не проверяет — это указание компилятору замолчать.
Дублирование типов DTO и модели. Три похожих интерфейса разъедутся. Выводи их друг из друга через Omit, Partial, Pick.
Tailwind
Динамическая сборка имён классов. `text-${size}` не попадёт в сборку — сканер ищет полные имена.
@apply вместо компонентов. Возвращает именованные классы и их проблемы, ради которых Tailwind и брали.
Произвольные значения вместо темы. p-[13px] и bg-[#1a2b3c] в каждом файле ломают единообразие. Место таких значений — в конфиге.
Попытка писать desktop first. Tailwind mobile first: класс без префикса — база для всех ширин.