React: компоненты, JSX, состояние, жизненный цикл, реконсиляция
В предыдущей статье мы собрали проект: Vite, модули, code splitting. Теперь — что именно мы собираем. React занимает в вакансиях и репозиториях место, несоразмерное его размеру: это библиотека на ~45 КБ (gzip, вместе с react-dom), которая делает ровно одну вещь — держит DOM синхронным с данными. Всё остальное — роутинг, запросы, стили, формы — экосистема вокруг.
Проблема в том, что большинство разработчиков учит React как набор заклинаний: «в зависимости useEffect положи это», «оберни в useCallback», «поставь key». Заклинания ломаются на первой нестандартной ситуации. Эта статья про модель: что такое элемент, чем рендер отличается от отрисовки, как работает сопоставление деревьев и почему setState внутри обработчика не меняет переменную сразу. Разобравшись в модели, вы перестанете угадывать.
Язык мы здесь не обсуждаем — типы, дженерики и настройка tsconfig разобраны в треке TypeScript. Код ниже на TypeScript просто потому, что так пишут в проде.
Задача: императивный DOM не масштабируется
Напишем счётчик подписчиков вручную, без библиотек.
// Императивно: мы описываем ПОСЛЕДОВАТЕЛЬНОСТЬ ДЕЙСТВИЙ над DOM
let count = 0;
document.querySelector('#btn').addEventListener('click', () => {
count += 1;
label.textContent = `Подписчиков: ${count}`; // не забыть обновить
badge.hidden = count === 0; // и это тоже
button.disabled = count >= 100; // и это
document.title = `${count} подписчиков`; // а вот это забыли
});
Каждое новое состояние требует новой ветки кода: «если стало ноль — спрятать бейдж, но не забыть про заголовок». Число переходов между состояниями растёт квадратично от числа состояний, и баги живут именно в непокрытых переходах. Классический симптом — «после третьего клика по фильтру список показывает старые данные».
React переворачивает задачу. Вы описываете не переходы, а результат: как выглядит интерфейс при данном состоянии. Переходы вычисляет библиотека.
function Subscribers() {
const [count, setCount] = useState(0);
// Одна функция описывает ВСЕ возможные состояния разом
return (
<>
<p>Подписчиков: {count}</p>
{count > 0 && <span className="badge">новое</span>}
<button onClick={() => setCount(count + 1)} disabled={count >= 100}>
Подписаться
</button>
</>
);
}
Формально: UI = f(state). Это та же идея, что и в декларативном CSS или в SQL — вы говорите «что», а не «как». Цена известна: между вашим кодом и DOM появляется прослойка, которая должна вычислить разницу. Дальше — как именно она это делает.
JSX: не HTML, не шаблон, а вызовы функций
<div className="row">{name}</div> — это не строка и не HTML. Это синтаксический сахар, который транспилятор (Babel, SWC, esbuild в Vite) превращает в вызов функции. С React 17 действует «новый JSX-трансформ»: импорт React больше не нужен, вызывается jsx из react/jsx-runtime.
// Исходник
const el = <Card title="Отчёт" onClose={close}><p>Тело</p></Card>;
// Во что превращает SWC (упрощённо)
import { jsx as _jsx } from "react/jsx-runtime";
const el = _jsx(Card, {
title: "Отчёт",
onClose: close,
children: _jsx("p", { children: "Тело" })
});
// А _jsx возвращает обычный неизменяемый объект — «элемент»
// { $$typeof: Symbol(react.element), type: Card, key: null, props: {...} }
Из этого следуют все правила JSX, которые обычно учат наизусть:
- Один корень. Функция возвращает одно значение. Нужно несколько соседей —
<>...</>(Fragment), он не создаёт узел DOM. className,htmlFor,tabIndex. Ключи объекта — свойства DOM-узла, аclassиfor— зарезервированные слова JS.- Заглавная буква имеет значение.
<div>компилируется вjsx("div", ...)— строка, значит хост-элемент платформы.<Card>компилируется вjsx(Card, ...)— ссылка на функцию, значит компонент. Написали<card>— React честно попробует создать неизвестный HTML-тег. - Фигурные скобки — это выражение, а не оператор.
{if (x) ...}невозможно; работают тернарник,&&, вызов функции. - Элемент — описание, а не то, что на экране. Создать его дёшево: это литерал объекта. Он не «монтируется», пока React не решит его отрендерить.
type, props, key, ref"] D --> E{"Что в поле type?"} E -->|"строка div, span"| F["Хост-элемент: узел платформы"] E -->|"функция Card"| G["Компонент: вызвать и получить
новые элементы"] G --> D F --> H["Реконсиляция: сравнение
с текущим деревом Fiber"] H --> I["Список эффектов:
вставка, обновление, удаление"] I --> J["Рендерер react-dom:
реальные операции с DOM"] J --> K["Браузер: стиль, раскладка, отрисовка"]
Последний шаг — тот самый конвейер из статьи о браузере. React не заменяет его, а лишь минимизирует число вызовов, которые до него доходят.
Отсюда же классическая ловушка условного рендера: {items.length && <List/>} при пустом массиве выведет на экран текст «0». React не рисует false, null, undefined и true, но рисует 0 и пустую строку — это валидные текстовые узлы, а && возвращает левый операнд, если он ложный. Правильно — {items.length > 0 && <List/>}.
Компонент: функция из props в элементы
Компонент — функция, принимающая объект props и возвращающая элементы. Всё. Классы существуют, но новый код на них не пишут: с 2019 года весь дизайн API строится вокруг функций и хуков.
type BadgeProps = {
children: React.ReactNode;
tone?: 'neutral' | 'success' | 'danger';
};
// Компонент — обычная функция. Props только читаются, никогда не мутируются.
export function Badge({ children, tone = 'neutral' }: BadgeProps) {
return <span className={`badge badge--${tone}`}>{children}</span>;
}
Данные текут вниз, события — вверх. Родитель передаёт значения через props, ребёнок сообщает о намерении через колбэк. Ребёнок не может изменить props — это гарантия, на которой держится вся предсказуемость: увидев значение props, вы знаете, что оно пришло ровно из одного места.
Композиция вместо конфигурации
Первая архитектурная развилка, на которой ошибаются: наращивать props или вкладывать элементы.
// ПЛОХО: props растут бесконечно, компонент знает про все случаи
<Dialog title="Удалить" icon="trash" showFooter footerAlign="right"
primaryText="Удалить" onPrimary={del} secondaryText="Отмена" />
// ХОРОШО: слоты. Dialog отвечает за поведение, содержимое даёт вызывающий
<Dialog onClose={close}>
<Dialog.Title>Удалить проект?</Dialog.Title>
<Dialog.Body>Действие необратимо.</Dialog.Body>
<Dialog.Footer>
<Button onClick={close}>Отмена</Button>
<Button tone="danger" onClick={del}>Удалить</Button>
</Dialog.Footer>
</Dialog>
children — не магия, а обычный prop типа ReactNode, и слотов может быть несколько: <Layout sidebar={<Nav />}>...</Layout> — это просто ещё один prop, в который передали элемент. Побочный эффект композиции — производительность: элемент, переданный через children, создан в родителе и при ре-рендере Layout не пересоздаётся, поэтому React пропустит его поддерево. Об этом ещё вспомним в разделе про тормоза.
Правила React: рендер должен быть чистым
Функция компонента вызывается React в момент, который вы не контролируете, и может быть вызвана несколько раз подряд или брошена на полпути. Отсюда жёсткие правила (react.dev/reference/rules):
- Одинаковые props и состояние → одинаковый результат. Никаких
Math.random()иnew Date()в теле рендера, если результат влияет на разметку. - Никаких мутаций: ни props, ни состояния, ни переменных модуля, ни DOM напрямую.
- Никаких запросов, таймеров и подписок в теле функции — только в обработчиках событий и эффектах.
function Report({ rows }: { rows: Row[] }) {
// rows.sort(...) — ПЛОХО: мутирует чужой массив прямо в рендере
return <Table rows={rows.toSorted((a, b) => a.date - b.date)} />; // ХОРОШО: копия
}
<StrictMode> в разработке специально вызывает компоненты и эффекты дважды, чтобы нечистота проявилась сразу, а не в проде под конкурентным рендером. Если приложение ломается в StrictMode — оно сломано, просто вы этого ещё не видели. Отключать StrictMode ради «двойных запросов» — лечить симптом.
Рендер — это не отрисовка
Слово «рендер» в React означает вызов функций компонентов и вычисление нового дерева элементов. Отрисовка пикселей — работа браузера, и она может вообще не понадобиться, если разметка не изменилась.
Цикл состоит из трёх шагов.
- Триггер — первый рендер (
createRoot().render()) или обновление состояния. - Фаза render — React вызывает компоненты, строит новое дерево, сравнивает со старым. Чистая, без побочных эффектов, прерываемая.
- Фаза commit — React применяет накопленные изменения к DOM. Синхронная, атомарная, непрерываемая.
батчатся в один рендер S->>R: фаза render — вызвать компоненты Note over R: чисто и прерываемо,
результат копится в workInProgress R->>R: реконсиляция, сбор списка эффектов R->>D: фаза commit — мутации DOM Note over D: синхронно, дерево
переключается атомарно R->>R: useLayoutEffect и его очистка D->>B: браузер считает стиль и раскладку, красит кадр B-->>R: после отрисовки — useEffect
Ключевое следствие: рендер не обязательно означает работу с DOM. Компонент может выполниться сто раз, и если результат совпал со старым — React не тронет ни один узел. Обратное тоже верно: «лишний рендер» — это лишние вызовы функций и сравнение объектов, обычно микросекунды. Оптимизировать нужно не рендеры вообще, а конкретный медленный рендер, найденный профайлером.
Состояние: снимок, а не переменная
Почему нельзя обычную переменную? Потому что при следующем вызове функции она создастся заново, а React не узнает, что нужно перерендерить. const [value, setValue] = useState(initial) решает обе задачи: хранит значение вне вызова функции (в узле Fiber) и уведомляет React об изменении.
Правило первое: состояние в рендере — снимок
function Counter() {
const [count, setCount] = useState(0);
function handleTripleClick() {
setCount(count + 1); // count === 0 → планируем 1
setCount(count + 1); // count всё ещё 0 → планируем 1
setCount(count + 1); // count всё ещё 0 → планируем 1
console.log(count); // 0 — переменная не изменилась и не изменится
}
// после обработчика: count === 1, а не 3
}
count — обычная константа, захваченная замыканием текущего рендера. setCount её не меняет: он ставит в очередь обновление и просит React отрендерить компонент заново. При следующем вызове функции будет создана новая константа count с новым значением. Это не баг и не асинхронность в привычном смысле — это гарантия консистентности: внутри одного рендера все значения соответствуют одному моменту времени.
Правило второе: функциональный апдейтер читает актуальное значение
setCount(c => c + 1); // c — значение из очереди, а не из замыкания
setCount(c => c + 1);
setCount(c => c + 1); // итого +3
Апдейтеры складываются в очередь и применяются по порядку в момент рендера. Используйте эту форму всегда, когда новое значение зависит от старого — особенно внутри setInterval, setTimeout, промисов и подписок, где замыкание давно устарело.
Правило третье: батчинг
React объединяет все обновления, случившиеся в одном тике, в один рендер. С React 18 это работает везде: в обработчиках, в setTimeout, в .then(), в нативных событиях. Нужно выйти из батча (редко, обычно ради измерения DOM) — flushSync из react-dom.
Правило четвёртое: неизменяемость
React сравнивает состояние через Object.is. Мутировали объект — ссылка та же, обновления не будет.
// ПЛОХО: ссылка не изменилась, ре-рендера не будет
user.name = 'Аня'; setUser(user);
todos.push(newTodo); setTodos(todos);
// ХОРОШО: новая ссылка
setUser(u => ({ ...u, name: 'Аня' }));
setTodos(ts => [...ts, newTodo]);
setTodos(ts => ts.map(t => t.id === id ? { ...t, done: !t.done } : t));
setTodos(ts => ts.filter(t => t.id !== id));
// Глубокая вложенность — признак плохой формы состояния.
// Либо нормализуйте по id, либо возьмите Immer (useImmer).
Правило пятое: ленивая инициализация
// ПЛОХО: parse выполняется на КАЖДОМ рендере, результат выбрасывается
const [draft, setDraft] = useState(JSON.parse(localStorage.getItem('draft') ?? '{}'));
// ХОРОШО: функция вызывается только при монтировании
const [draft, setDraft] = useState(() => JSON.parse(localStorage.getItem('draft') ?? '{}'));
Как выбирать форму состояния
- Не дублируйте то, что выводится.
fullNameизfirst + last— не состояние, а выражение в теле рендера. Дублирование = рассинхронизация. - Группируйте то, что меняется вместе. Координаты
{x, y}— один объект, а не дваuseState. - Избегайте противоречивых комбинаций. Вместо
isLoading+isError+data— один дискриминированный союз:{ status: 'idle' | 'loading' | 'success' | 'error' }. Невалидное состояние должно быть невыразимым. - Держите состояние как можно ниже. Локальное состояние формы не должно жить в глобальном сторе. Подробнее — Управление состоянием.
- Поднимайте вверх ровно до общего предка двух компонентов, которым оно нужно. Аккордеон, где открыта одна панель, хранит
openIndexв родителе, аPanelполучаетisOpenиonOpen— сама она ничего не помнит.
Реконсиляция: как React сравнивает деревья
После рендера у React два дерева: то, что на экране, и то, что должно быть. Задача — найти минимальный набор операций. Классический алгоритм сравнения деревьев работает за O(n³) — для тысячи узлов это миллиард операций, неприемлемо. React использует эвристический алгоритм за O(n), опирающийся на два допущения, верных для реальных интерфейсов:
- Элементы разных типов дают разные деревья. Если на позиции был
<div>, а стал<span>(или был<Chart>, а стал<Table>) — React не пытается их сопоставить: старое поддерево размонтируется целиком со всем состоянием и эффектами, новое монтируется с нуля. - Разработчик подсказывает стабильность через
key. Внутри списка соседей React сопоставляет детей по ключу, а не по позиции.
Если тип совпал — React оставляет узел DOM на месте, обновляет только изменившиеся атрибуты и рекурсивно идёт к детям.
Позиция в дереве — это идентичность
Состояние привязано не к компоненту, а к месту в дереве UI. Отсюда два симметричных сюрприза.
// СЮРПРИЗ 1: состояние сохраняется, хотя «компонент другой».
// Обе ветки дают Counter на первой позиции внутри div — это один и тот же fiber.
<div>{isFancy ? <Counter fancy /> : <Counter />}</div>
// СЮРПРИЗ 2: состояние сбрасывается при «косметическом» изменении разметки.
// section и div — разные типы, поддерево пересоздаётся.
{isFancy ? <section><Counter /></section> : <div><Counter /></div>}
Отсюда рабочий приём: key как способ сбросить состояние.
// Форма редактирования должна полностью обнулиться при смене пользователя
<UserForm key={userId} user={user} />
// Без key: React считает это тем же компонентом, поля сохранят чужие данные
Это официально рекомендованная замена устаревшему getDerivedStateFromProps и хаку с useEffect, сбрасывающим состояние.
Списки и key
{users.map((u, i) => <UserRow key={i} user={u} />)} // ПЛОХО при вставках и сортировке
{users.map(u => <UserRow key={Math.random()} user={u} />)} // ПЛОХО: список пересоздаётся весь
{users.map(u => <UserRow key={u.id} user={u} />)} // ХОРОШО: стабильный id из данных
Требования к ключу: стабильный между рендерами, уникальный среди соседей (не глобально), выводимый из данных. Нет id с сервера — сгенерируйте его при создании элемента (crypto.randomUUID()) и храните в данных, а не вычисляйте в рендере. Индекс допустим только для списка, который никогда не меняет порядок и длину.
Симптомы неправильного ключа узнаваемы: отмеченный чекбокс «переезжает» на соседнюю строку, введённый текст остаётся в поле после удаления записи, CSS-анимация проигрывается не на том элементе, поле теряет фокус при каждом нажатии клавиши.
Fiber: как это устроено внутри
До 2017 года React обходил дерево рекурсивно, и обход нельзя было остановить: длинный рендер блокировал главный поток и ронял отзывчивость. Fiber — переписанное ядро, где узел дерева стал объектом с явными указателями, а рекурсия — циклом.
| Поле | Что хранит | Зачем знать |
|---|---|---|
tag |
вид узла: функция, хост-элемент, фрагмент, Suspense |
по нему React решает, как обрабатывать узел |
type |
функция компонента или строка тега | смена type = размонтирование поддерева |
stateNode |
реальный узел DOM или корень | связь виртуального дерева с платформой |
child / sibling / return |
указатели обхода | позволяют идти циклом, а не рекурсией |
alternate |
тот же узел в другом дереве | двойная буферизация без аллокаций |
memoizedState |
связный список хуков | вот почему хуки нельзя вызывать в условии |
flags |
что сделать в коммите | Placement, Update, Deletion |
lanes |
битовая маска приоритетов | ввод текста важнее фоновой загрузки |
Из memoizedState как связного списка следует главное правило хуков: React не знает их имён, он идентифицирует хук порядком вызова. Поставили useState внутрь if — при следующем рендере третий хук получит состояние второго. Именно это ловит правило react-hooks/rules-of-hooks в ESLint.
Работа в фазе render — цикл while (workInProgress !== null), который на каждом шаге может проверить shouldYield() и вернуть управление браузеру. Отсюда конкурентные возможности: useTransition помечает обновление как несрочное (набор текста в поиске не блокируется перестройкой тяжёлого списка), Suspense даёт декларативную загрузку, useDeferredValue показывает устаревшее значение, пока считается новое. Подробности — в статьях о данных и рендеринге.
Отдельно стоит развеять миф: виртуальный DOM не быстрее прямых манипуляций. Прямой вызов textContent всегда дешевле, чем построить дерево объектов, сравнить его и потом вызвать тот же textContent. React выигрывает не в скорости, а в том, что даёт «достаточно быстро» без ручной оптимизации и без класса ошибок рассинхронизации. Это инженерный размен, а не превосходство.
Жизненный цикл функционального компонента
Классических componentDidMount и componentDidUpdate в функциях нет — и это не упрощение, а другая модель. Вместо «что сделать в такой-то момент» вы описываете синхронизацию с внешней системой: «пока компонент на экране с такими параметрами, должна существовать вот эта подписка».
function ChatRoom({ roomId }: { roomId: string }) {
const [messages, setMessages] = useState<Message[]>([]);
useEffect(() => {
const socket = connect(roomId); // «включить» синхронизацию
socket.on('message', m => setMessages(p => [...p, m]));
return () => socket.disconnect(); // «выключить» — обязательно
}, [roomId]); // сменился roomId → выключить старое, включить новое
return <MessageList items={messages} />;
}
Функция очистки вызывается не только при размонтировании, но и перед каждым повторным запуском эффекта. Мысленная модель «монтирование/размонтирование» здесь вредна; правильная — «эффект должен уметь запускаться и останавливаться сколько угодно раз». Именно это проверяет двойной вызов в StrictMode.
Порядок в одном коммите: мутации DOM → useLayoutEffect (синхронно, до отрисовки; здесь измеряют геометрию и правят позицию тултипа, чтобы не мигало) → браузер красит кадр → useEffect (асинхронно, после отрисовки; всё остальное). Внутри дерева эффекты выполняются снизу вверх: дети раньше родителей.
Глубокий разбор useEffect, зависимостей, useRef, мемоизации и собственных хуков — в следующей статье.
Почему тормозит и как это измерять
Сначала диагноз, потом лечение. В React-приложении есть ровно четыре источника медленности, и лечатся они по-разному.
- Огромный бандл. Приложение не тормозит — оно ещё не приехало. Смотрите LCP и размер JS; лечится code splitting и
React.lazy(сборка, производительность). - Каскад рендеров. Состояние лежит слишком высоко:
setStateв корне перерисовывает всё дерево. Лечится опусканием состояния вниз, композицией черезchildrenиmemoна границах. - Дорогой отдельный рендер. Один компонент сортирует 50 000 строк прямо в теле функции. Лечится вынесением вычисления, виртуализацией списков (
@tanstack/virtual), пагинацией. - Дорогой коммит. Тысячи узлов DOM создаются разом; тормозит уже браузер — раскладка и отрисовка. Лечится сокращением числа узлов,
content-visibility, виртуализацией.
Единственный способ отличить второе от третьего — профайлер. React DevTools Profiler показывает флейм-график коммитов: ширина полосы — время рендера компонента, а «Why did this render?» (включается в настройках) называет причину: props, hook, parent.
// Программное измерение — работает и в проде, если собрать с profiling-сборкой
import { Profiler } from 'react';
<Profiler
id="ProductList"
// actualDuration — с учётом мемоизации; baseDuration — сколько было бы без неё
onRender={(id, phase, actualDuration) => {
if (actualDuration > 16) analytics.track('slow_render', { id, phase, actualDuration });
}}
>
<ProductList items={items} />
</Profiler>
Пользователь же чувствует не «рендеры», а INP (Interaction to Next Paint) — время от нажатия до отрисовки следующего кадра. Бюджет: 200 мс на 75-м перцентиле. Замеряйте полем, а не в лаборатории:
// npm i web-vitals — отправляем реальные метрики пользователей
import { onINP, onLCP, onCLS } from 'web-vitals';
const send = (m) => navigator.sendBeacon('/rum', JSON.stringify(m));
onINP(send); onLCP(send); onCLS(send);
В Performance-панели DevTools ищите длинные задачи (> 50 мс) с жёлтыми блоками performOnConcurrentRoot — это фаза render; фиолетовые Layout/Paint сразу после — коммит оказался тяжёлым для браузера. Разметка performance.measure из React 18 подписывает задачи именами компонентов.
Отдельно: React Compiler (стабилен с 2025 года, ставится как плагин Babel/SWC) автоматически мемоизирует компоненты и значения, анализируя код на чистоту. Он снимает большую часть ручных useMemo и useCallback — но только если ваш код следует правилам React. Ещё один аргумент не спорить со StrictMode.
Чего React не делает
React — библиотека представления. У неё нет мнения о роутинге, запросах, стилях, формах и валидации. Это одновременно сила (можно собрать стек под задачу) и слабость (нужно принять десяток решений до первой строки бизнес-логики).
Честное сравнение: почему не Vue, Svelte, Solid или Angular
Модель React — пересчёт сверху вниз: изменилось состояние, перевызываем поддерево, сравниваем результат. Это делает код предсказуемым (компонент — просто функция) и ценой этого — лишняя работа, которую приходится ограничивать вручную или компилятором.
- Solid и Svelte 5 используют сигналы: обновление точечно меняет ровно те узлы DOM, которые зависят от значения, без сравнения деревьев. Быстрее и меньше по размеру, ручная мемоизация не нужна. Плата — компонент выполняется один раз, и интуиция «это обычная функция JS» ломается; экосистема и рынок труда несопоставимы по объёму.
- Vue 3 — компромисс: реактивность на прокси плюс виртуальный DOM с компиляторными подсказками. Отличный DX, официальные роутер и стор из коробки, меньше решений на старте. В корпоративном сегменте вне Азии вакансий заметно меньше.
- Angular — не библиотека, а платформа: DI, RxJS, схематики, строгая структура. Выигрывает в больших долгоживущих корпоративных системах с ротацией команд, где ценна одинаковость. Порог входа выше, гибкости меньше; с сигналами в v16+ разрыв в производительности закрылся.
- React выигрывает объёмом экосистемы, зрелостью инструментов (DevTools, Testing Library, RSC), количеством разработчиков на рынке и тем, что почти любая задача уже кем-то решена. Для команды это часто важнее миллисекунд.
Практическое правило: разница в производительности фреймворков на реальных продуктовых экранах обычно на порядок меньше, чем разница из-за размера бандла, числа запросов и отсутствия виртуализации. Развёрнутое сравнение с бенчмарками — в статье Сравнение фреймворков.
Типичные ошибки
useStateдля значения, которое выводится из props. Копия рассинхронизируется. Считайте в рендере или используйтеkeyдля сброса.- Мутация состояния —
arr.push,obj.field = x,arr.sort(). React сравнивает ссылки, ре-рендера не будет. Массивы:toSorted,toReversed,with,map,filter. key={index}в изменяемом списке. Состояние и фокус остаются на чужой строке.- Побочный эффект в теле компонента — запрос, запись в
localStorage, изменение переменной модуля. Ломается в StrictMode и под конкурентным рендером. - Компонент, объявленный внутри другого компонента. На каждом рендере это новая функция → новый
type→ размонтирование поддерева со всем состоянием. Выносите наружу. - Чтение
countсразу послеsetCount. Значение из замыкания текущего рендера. Нужна зависимость от предыдущего — функциональный апдейтер. useEffectвместо обработчика события. Отправка формы — это событие, а не синхронизация с внешней системой.- Условные и вложенные хуки. Порядок вызова — идентификатор хука; сдвиг ломает всё состояние компонента.
- Мемоизация без измерения.
memoиuseCallbackвезде добавляют сравнения и удерживают ссылки; на быстрых компонентах это чистый убыток. Иmemoбесполезен, если в props летит литерал:<Chart config={{ theme: 'dark' }} />— новый объект на каждом рендере. - Отключённый StrictMode ради «двойных запросов»: вы просто перестали видеть реальный баг.
Мини-итог
React — это одна идея, доведённая до продакшн-качества: интерфейс есть функция состояния, а синхронизацию с DOM берёт на себя библиотека. JSX компилируется в вызовы jsx(), возвращающие неизменяемые объекты-элементы — дешёвое описание, а не сам UI. Компонент обязан быть чистой функцией, потому что React волен вызвать его дважды или бросить рендер на полпути. Состояние — снимок, привязанный к позиции в дереве, а не к переменной; отсюда и функциональные апдейтеры, и key как способ сбросить компонент. Реконсиляция сводит сравнение деревьев к O(n) двумя эвристиками — «разный тип = новое поддерево» и «ключи задают соответствие», — и почти все загадочные баги списков растут из нарушения второй. Fiber превращает рекурсию в прерываемый цикл и хранит хуки связным списком, что объясняет правила хуков. А тормозит приложение обычно не из-за «лишних рендеров», а из-за размера бандла, состояния, поднятого слишком высоко, и тысяч неотвиртуализированных узлов — и это выясняется профайлером, а не интуицией.
Источники
- react.dev — Learn, особенно State as a Snapshot, Preserving and Resetting State, Rules of React
- Reconciliation (устаревшая, но по-прежнему лучшая по алгоритму статья)
- Andrew Clark, React Fiber Architecture
- Rodrigo Pombo, Build your own React — реализация ядра в 200 строк
- Mark Erikson, A (Mostly) Complete Guide to React Rendering Behavior
- React Compiler, React DevTools Profiler
- web.dev: INP, библиотека web-vitals
- Исходники React —
ReactFiberBeginWork.jsиReactChildFiber.jsчитаемее, чем кажется
Что дальше
Модель ясна: компоненты, элементы, состояние, реконсиляция. Осталось научиться жить в ней ежедневно — правильно описывать зависимости эффектов, синхронизироваться с внешним миром, не терять данные в замыканиях, мемоизировать по делу и вытаскивать логику в собственные хуки, которые переиспользуются между экранами.
Хуки и паттерны React: useState, useEffect, мемоизация, композиция