Вы меняете состояние, и на экране появляется новое число. Между этими двумя событиями React делает работу, о которой большинство разработчиков не думает, пока она не начнёт мешать: список дёргается при удалении элемента, компонент перерисовывается по двадцать раз, console.log печатает дважды, а значение после setState оказывается старым. Все эти странности объясняются одной моделью. Разберём её по шагам: от функции компонента до реального DOM.
Компонент как чистая функция
Функциональный компонент это функция от пропсов и состояния к описанию интерфейса. React ждёт от неё чистоты в математическом смысле: одинаковые входы дают одинаковый результат, а побочных действий внутри нет. Запрос к серверу, запись в localStorage, изменение внешней переменной прямо в теле компонента нарушают это правило, и для них есть эффекты и обработчики событий.
Чистота нужна не ради красоты. React оставляет за собой право вызвать компонент сколько угодно раз и в любой момент: чтобы сравнить результат с прошлым, чтобы подготовить рендер заранее, чтобы прервать его и начать заново. StrictMode в разработке намеренно вызывает каждый компонент дважды, и если второй вызов что-то ломает, компонент нечистый. Отсюда же правило про неизменяемость: состояние не мутируют, а заменяют новым объектом, иначе React не увидит, что оно изменилось.
Virtual DOM: описание, а не экранспросят на собеседовании
Функция компонента возвращает не DOM-узлы, а лёгкие объекты-описания: элемент такого типа с такими пропсами и такими детьми. JSX это синтаксис для их создания, который Babel или TypeScript превращают в вызовы функций. Дерево таких описаний и называют Virtual DOM.
Зачем лишний слой? Менять настоящий DOM дорого: каждое изменение может заставить браузер пересчитать раскладку. А создавать объекты JavaScript дёшево. Поэтому React строит новое описание целиком, сравнивает его со старым и применяет к DOM только разницу. Важно понимать, что «Virtual DOM быстрее DOM» неверная фраза: прямое точечное изменение DOM всегда быстрее, чем сравнение деревьев плюс то же изменение. Virtual DOM покупает не скорость, а возможность писать компонент как чистую функцию и не думать, что именно поменялось.
Согласование: как React сравнивает деревьяспросят на собеседовании
Сравнение двух деревьев в общем случае стоит кубического времени, и React заменяет его эвристикой из двух правил. Первое: если тип элемента изменился, div стал span или один компонент другим, всё поддерево выбрасывается и строится заново вместе с состоянием. Второе: дети сравниваются по позиции, а в списках по ключу.
Отсюда роль key. Без ключей React сопоставляет элементы списка по индексу: удалили первый элемент, и React решит, что первый элемент изменил содержимое, а последний исчез, и обновит все узлы по очереди. С ключами он понимает, что элемент с таким ключом просто пропал, а остальные остались на месте, и переставит узлы. Индекс массива как ключ работает ровно до первой перестановки или удаления: состояние полей ввода, анимации и фокус перескочат на чужой элемент.
const items = [{ id: 7, name: "Чай" }, { id: 3, name: "Кофе" }];
const good = items.map((item) => <li key={item.id}>{item.name}</li>);
const bad = items.map((item, index) => <li key={index}>{item.name}</li>);
Смена ключа у одного элемента это команда «считай его новым»: состояние сбросится, эффекты выполнятся заново. Этим пользуются намеренно, чтобы сбросить форму при смене записи.
Две фазы: render и commitспросят на собеседовании
Работа React делится на две фазы. В фазе render он вызывает компоненты и строит новое дерево, в фазе commit применяет изменения к DOM и запускает эффекты. Фаза render чистая и может быть прервана или выброшена, фаза commit синхронная и неделимая: пользователь не увидит полуобновлённый экран.
Эффекты тоже разложены по фазам. useLayoutEffect выполняется синхронно сразу после изменения DOM, до того как браузер нарисует кадр: в нём измеряют размеры и двигают элементы без мерцания. useEffect выполняется после отрисовки, не задерживая кадр: запросы, подписки, логирование. Классовые методы жизненного цикла легли на эту же схему: componentDidMount и componentDidUpdate это эффект, componentWillUnmount его функция очистки.
Fiber: работа, которую можно прерватьспросят на собеседовании
До 2017 года рендер был рекурсивным вызовом и не мог остановиться на середине: большое дерево блокировало поток, и ввод пользователя ждал. Fiber это переписанное ядро, где дерево компонентов превращено в связный список единиц работы. Каждая единица, волокно, это один компонент с его пропсами, состоянием и ссылкой на DOM-узел, и React обрабатывает их по одной, проверяя между ними, не пора ли отдать поток браузеру.
Из этого выросли возможности React 18: конкурентный рендер, где срочное обновление, например ввод в поле, прерывает несрочное, например фильтрацию длинного списка; useTransition и useDeferredValue, которые помечают обновление как несрочное; Suspense, который показывает запасной интерфейс, пока данные или код грузятся. Все они держатся на том, что фаза render чистая и её можно выбросить без последствий.
Что вызывает перерисовкуспросят на собеседовании
Компонент перерисовывается в трёх случаях: изменилось его состояние, перерисовался родитель, изменилось значение контекста, который он читает. Пропсы сами по себе причиной не являются: React не сравнивает их по умолчанию, он просто вызывает дочерние компоненты вслед за родителем. Поэтому обёртка родителя в компонент-контейнер перерисовывает всё дерево под ним, а контекст перерисовывает всех подписчиков разом, даже если им нужно одно поле из десяти.
React.memo добавляет сравнение пропсов: компонент пропускает рендер, если ни один проп не изменился по ссылке. Это работает, пока пропсы стабильны: объект или функция, созданные в теле родителя, каждый раз новые, и memo их не спасёт без useMemo и useCallback. Отсюда правило из статьи про производительность: сначала профайлер, потом точечная мемоизация, а не memo на всём подряд.
Батчинг и «асинхронный» setStateспросят на собеседовании
setState не меняет состояние на месте: он ставит обновление в очередь и помечает компонент к перерисовке. Поэтому строка после setCount(count + 1) видит старое значение, а два вызова setCount(count + 1) подряд увеличивают счётчик на один. Нужно опереться на предыдущее значение, передают функцию: setCount((c) => c + 1).
Несколько обновлений в одном обработчике React склеивает в одну перерисовку, это батчинг. С React 18 он работает везде: в обработчиках событий, в промисах, в таймерах. Так что три setState подряд дадут один рендер, и спорить о порядке бессмысленно. Если обновление нужно применить синхронно, например измерить DOM сразу после, есть flushSync, и это редкий инструмент.
Коротко
- Компонент это чистая функция от пропсов и состояния; React вправе вызывать её сколько угодно раз, а
StrictModeделает это дважды в разработке. - Virtual DOM это дерево описаний, по разнице которых React точечно меняет DOM; он покупает не скорость, а модель «описываю, а не меняю».
- Согласование: другой тип элемента означает пересоздание поддерева; дети сравниваются по позиции, в списках по
key, индекс как ключ ломается при перестановке. - Две фазы: render чистая и прерываемая, commit синхронная;
useLayoutEffectдо кадра,useEffectпосле. - Fiber разбил рендер на прерываемые единицы работы, на нём стоят конкурентный рендер,
useTransition,useDeferredValueиSuspense. - Перерисовку вызывают своё состояние, родитель и контекст;
memoсравнивает пропсы по ссылке и бесполезен без стабильных пропсов. setStateставит обновление в очередь, обновления батчатся в один рендер; для зависимости от прошлого значения передают функцию.
Что почитать дальше
- Хуки: кастомные и ловушки useEffect — где в этой модели живут эффекты и почему массив зависимостей не формальность.
- Производительность React — профайлер, точечная мемоизация, виртуализация длинных списков.
- Event loop в браузере — почему рендер делят на куски и как длинная задача блокирует ввод.
- Состояние и данные — локальное состояние, контекст и внешние сторы с точки зрения перерисовок.