Как React обновляет страницу
Почему это важно
В прошлых темах мы писали setCount(count + 1), и «React перерисовывал компонент». Пора разобраться,
что именно при этом происходит. Без этого React остаётся магией, а большинство ошибок новичков —
«почему экран не обновился», «почему значение старое», «почему поле не очистилось» — непонятны.
После этой темы ты сможешь предсказать, что сделает React, ещё до запуска кода.
Три шага: вызвать, сравнить, применить
Любое обновление экрана в React проходит три шага:
- Запуск (trigger). Что-то просит обновления: первая отрисовка
root.render(...)или вызов функцииset…изuseState. - Отрисовка (render). React вызывает функции-компоненты. Каждая возвращает JSX — описание того, что должно быть на экране. Сама страница на этом шаге ещё не меняется.
- Применение (commit). React сравнивает новое описание с предыдущим и меняет в настоящем DOM
только то, что отличается. После этого запускаются эффекты (
useEffect).
«Отрисовка» в React — это не рисование пикселей, а просто вызов твоей функции.
Что возвращает JSX
JSX — это не HTML. Сборщик превращает каждый тег в вызов React.createElement, а тот возвращает
обычный объект:
const element = <h1 className="title">Привет</h1>;
// после сборки:
const element = React.createElement("h1", { className: "title" }, "Привет");
// это объект примерно такого вида:
// { type: "h1", props: { className: "title", children: "Привет" } }
Дерево таких объектов называют виртуальным DOM. Создавать объекты очень дёшево, а вот менять настоящую страницу дорого: браузеру приходится пересчитывать расположение и перерисовывать пиксели. Поэтому React каждый раз строит новое лёгкое дерево целиком, а в настоящий DOM вносит минимальные правки.
Сравнение деревьев
Сравнение старого и нового дерева называется reconciliation (согласование). Правила простые:
- элемент того же типа на том же месте — React оставляет DOM-узел и меняет только изменившиеся атрибуты и текст;
- другой тип (
<p>стал<div>,<Chat>стал<Profile>) — старый узел со всем, что внутри, удаляется, создаётся новый; - в списках элементы сопоставляются по
key, а не по порядку.
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<h1>Счётчик</h1>
<p>Нажато: {count}</p>
<button onClick={() => setCount(count + 1)}>+1</button>
</div>
);
}
После клика React вызывает Counter снова, получает новое дерево и видит: div, h1 и button
не изменились, а у p поменялся текст. В DOM меняется один текстовый узел. Если в поле ввода
на странице был курсор, он не пропадёт: поле никто не пересоздавал.
Когда компонент перерисовывается
Компонент вызывается заново, когда:
- изменилось его состояние (вызвали
set…с новым значением); - перерисовался его родитель — тогда перерисовываются все дети, даже если их props не менялись;
- изменился контекст, который он читает (тема про
useContext).
Убедиться можно, добавив console.log прямо в тело компонента:
function Child({ label }) {
console.log("render Child", label);
return <p>{label}</p>;
}
function Parent() {
const [n, setN] = useState(0);
console.log("render Parent", n);
return (
<div>
<button onClick={() => setN(n + 1)}>{n}</button>
<Child label="я не меняюсь" />
</div>
);
}
Каждый клик печатает и render Parent, и render Child. Это нормально и почти всегда быстро:
отрисовка — лишь вызов функции, а DOM для Child не меняется. Как пропускать лишние отрисовки,
когда это действительно нужно, — в теме про производительность.
Отсюда важное правило: компонент должен быть «чистой» функцией. При одинаковых props и
состоянии он возвращает одинаковый JSX и ничего не меняет снаружи: не отправляет запросы, не
меняет глобальные переменные, не трогает DOM. React может вызвать его сколько угодно раз. Всё
«внешнее» — в обработчиках событий и в useEffect.
Состояние — это снимок
Внутри одной отрисовки значение состояния не меняется. count — обычная константа той
отрисовки, в которой создан обработчик:
function Counter() {
const [count, setCount] = useState(0);
function handleClick() {
setCount(count + 1);
console.log(count); // всё ещё 0!
}
return <button onClick={handleClick}>{count}</button>;
}
setCount не меняет переменную count. Он говорит React: «в следующей отрисовке дай
count значение 1». Новое значение появится, только когда React снова вызовет Counter.
Пачки обновлений
Если в одном обработчике вызвать несколько set…, React не перерисовывает компонент после
каждого: он собирает их в пачку (batching) и делает одну отрисовку после обработчика.
function handleClick() {
setCount(count + 1); // count = 0 → «поставь 1»
setCount(count + 1); // count всё ещё 0 → «поставь 1»
setCount(count + 1); // «поставь 1»
}
// итог: 1, а не 3
Если новое значение зависит от предыдущего, передай функцию обновления. React поставит такие функции в очередь и вызовет их по порядку, каждой давая результат предыдущей:
function handleClick() {
setCount((c) => c + 1); // 0 → 1
setCount((c) => c + 1); // 1 → 2
setCount((c) => c + 1); // 2 → 3
}
Правило: если следующее значение считается из текущего — пиши set((старое) => новое).
Нельзя менять состояние напрямую
Как React узнаёт, что состояние изменилось? Сравнивает старое и новое значение через Object.is
(почти то же, что ===). Для объектов и массивов это сравнение ссылок, а не содержимого:
const [items, setItems] = useState(["a", "b"]);
function addWrong() {
items.push("c"); // изменили тот же массив
setItems(items); // та же ссылка → React: «ничего не изменилось» → отрисовки нет
}
function addRight() {
setItems([...items, "c"]); // новый массив → новая ссылка → отрисовка
}
Поэтому состояние в React считают неизменяемым: вместо изменения создают новую копию.
| Действие | Массив | Объект |
|---|---|---|
| Добавить | [...arr, x] |
{ ...obj, key: value } |
| Удалить | arr.filter((x) => x.id !== id) |
const { key, ...rest } = obj |
| Изменить | arr.map((x) => (x.id === id ? { ...x, done: true } : x)) |
{ ...obj, key: newValue } |
| Сортировать | [...arr].sort(...) |
— |
Методы push, splice, sort, reverse и присваивание obj.key = … меняют исходник — для
состояния их не используют (или сначала делают копию).
Помни, что копия через ... — поверхностная: вложенные объекты нужно копировать тоже
({ ...user, address: { ...user.address, city: "Худжанд" } }).
Где живёт состояние: место в дереве
Состояние хранится не «внутри функции», а в React — привязанным к месту компонента в дереве. Пока на этом месте стоит компонент того же типа, состояние сохраняется, даже если props поменялись:
{selected === "ann" ? <Chat contact={ann} /> : <Chat contact={timur} />}
Для React здесь один и тот же <Chat> на одном месте — у него просто поменялись props.
Черновик сообщения, набранный для Ани, останется и для Тимура. Иногда это ошибка.
Чтобы сказать «это другой экземпляр, начни с чистого листа», используют key — не только в
списках:
<Chat key={contact.id} contact={contact} />
Новый key — React удаляет старый компонент вместе с состоянием и создаёт новый. Это обычный
способ сбросить форму или виджет при смене выбранного элемента.
Обратное тоже верно: если компонент пропал из дерева (например, через {show && <Panel />}),
его состояние уничтожается. Когда он появится снова, начнёт с начальных значений.
Строгий режим
В проектах приложение обычно оборачивают в <React.StrictMode>. В режиме разработки он нарочно
вызывает компоненты дважды и монтирует эффекты дважды (запуск → очистка → запуск), чтобы
быстрее найти нечистые компоненты и эффекты без очистки. Если видишь двойные console.log —
это он. В готовой сборке двойных вызовов нет.
Итог
- Обновление: запуск (
set…) → отрисовка (вызов компонентов) → применение (минимальные правки DOM) → эффекты. - JSX — это объекты; React сравнивает новое дерево со старым и меняет только разницу.
- Компонент перерисовывается при изменении своего состояния, при отрисовке родителя и при изменении контекста. Компонент — чистая функция.
- Состояние — снимок отрисовки; обновления собираются в пачку. Из старого значения —
set((old) => ...). - Состояние не меняют напрямую: новые массивы и объекты через
...,map,filter. - Состояние привязано к месту в дереве;
keyсоздаёт новый экземпляр и сбрасывает состояние.