Триггер, рендер, коммит

состояние≈ 10 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Между нажатием на кнопку и новым числом на экране React делает три разных шага: принимает триггер — заявку на перерисовку, выполняет рендер — вызывает вашу функцию, и проводит коммит — правит DOM. Пока они слиты в одно слово «перерисовка», не объяснить, почему сразу после setCount на экране старое число, зачем перерисовался дочерний компонент, у которого ничего не менялось, и почему React требует от рендера чистоты.

Разделы темы:·

В части II триггером были вы сами: изменили массив задач — тут же позвали render(), и разметка перестроилась раньше, чем обработчик дошёл до следующей строки. В React триггера два — первый root.render() и любой вызов сеттера из useState, — и ни один из них ничего не перестраивает. Оба лишь записывают заявку. Проверьте на первом: в каком порядке напечатаются четыре строки?

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Строка «рендер App» — последняя. <App /> не вызывает App: это объект-описание из статьи «JSX — это не HTML», и в его поле type лежит сама функция, нетронутая. root.render() тоже её не вызывает — он кладёт описание в очередь и возвращает управление. React доберётся до заявки, когда текущий скрипт отработает до конца. Ровно поэтому примеры учебника нажимают кнопку через setTimeout: сразу после root.render() кнопки в DOM ещё нет.

Второй триггер — сеттер состояния. Он делает то же самое: записывает заявку и возвращается. Что напечатается раньше — «после setCount» или «рендер, count = 1»?

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Обработчик допечатывает свои строки до конца, и только потом React берётся за заявку. Как он при этом складывает несколько заявок в одну и разбирает очередь — разобрано в статье про функцию-обновитель; здесь важно другое: триггер никогда не вызывает компонент сам. Он лишь помечает, что компоненту пора перерисоваться, а когда именно — решает React.

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

Рендер — это вызов функции компонента: React зовёт её, получает описание разметки и на этом шаге больше ничего не делает. В предыдущих статьях этот шаг назывался отрисовкой, и его уже считали по console.log в теле компонента. Вопрос, который там не поднимался: чьи функции вызываются по одной заявке? Ниже состояние есть только у Page. Сколько строк «рендер» добавит один клик — и какие именно?

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Три строки на первую отрисовку и ещё три после клика. Title получил тот же текст «Счётчик», у Footer вообще нет ни пропсов, ни состояния — и всё же обе функции вызвались заново. React не сравнивает пропсы, прежде чем вызвать компонент: описание, которое вернул Page, содержит <Title /> и <Footer />, значит, их функции нужно вызвать, чтобы узнать, что внутри. Рендер идёт вниз по дереву до самого конца.

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

Вниз — да, но не вверх и не вбок. Два одинаковых счётчика в одном родителе; клик по левому — что напечатается после первых трёх строк?

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Одна строка. Заявку подал левый счётчик — перерисовался он и всё, что он возвращает (здесь — одна кнопка). App не вызывался: его состояние не менялось, а описание, из которого растёт левый счётчик, у React уже есть. Правый счётчик не вызывался тем более: его ячейка состояния лежит отдельно, как показано в статье про useState.

Правило рендера в одну строку: перерисовывается компонент, чьё состояние изменилось, и всё, что он возвращает. Родители и соседи — нет.

Пока идут вызовы функций, страница неподвижна: React собирает новые описания, сравнивает их со старыми и лишь потом, одним заходом, вносит разницу в DOM. Этот последний шаг и называется коммитом. Границу между рендером и коммитом можно увидеть, подсмотрев в DOM прямо из тела компонента — в рабочем коде так не делают, но для диагностики сгодится. Что окажется «на экране» во второй строке вывода?

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

В первом рендере на экране undefined: DOM ещё пуст, абзаца #out не существует — его создаст коммит. Во втором count уже единица, а в DOM по-прежнему ноль: функция выполняется, коммит впереди. И только третья строка, напечатанная из таймера, видит новое число.

На коммите React правит ровно то, что изменилось: текстовый узел внутри <p> получит новое значение, а сам абзац, кнопка и обёртка останутся теми же объектами — та самая гарантия из статьи «Зачем React, если вы уже писали рендер из состояния», благодаря которой поле ввода не теряет фокус. Если описание совпало со старым целиком, коммиту нечего делать, и DOM не тронут вовсе, хотя рендер состоялся.

ШагКто запускаетЧто происходитКак увидеть в консоли
ТриггерВаш код: root.render() или сеттер состоянияReact записывает заявку и возвращает управлениеСтроки обработчика печатаются целиком, до строки из тела компонента
РендерReactВызывает функцию компонента и всех компонентов ниже негоconsole.log в теле компонента — по строке на вызов
КоммитReactСравнивает новое описание со старым и правит в DOM только разницуDOM обновлён после всех вызовов: читать его через setTimeout

Слово «коммит» здесь значит то же, что в базах данных и git: изменения копятся, а применяются одним неделимым шагом. Пользователь не увидит DOM «наполовину новым».

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

Вот компонент, который условие нарушает: он нумерует гостей во внешней переменной. При первой отрисовке список выглядит правильно — 1, 2, 3. Что в нём окажется после клика по «Перерисовать»?

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Гости стали четвёртым, пятым и шестым, хотя ни пропсов, ни состояния у них нет и «по смыслу» ничего не менялось. Номер зависит от того, сколько раз и в каком порядке React вызывал функцию, — а этого React никому не обещал. Включите в таком проекте StrictMode — и уже при первой отрисовке будет 2, 4, 6.

Правил чистоты три, и все они про одно: не трогать то, что существовало до вызова. Нельзя менять внешние переменные, пропсы и объекты из состояния — только читать. Нельзя трогать DOM: ни создавать узлы, ни менять document.title, ни вешать обработчики — всё это работа коммита, а для побочных действий после коммита есть отдельный механизм — эффекты; знакомство с ними начинается со статьи «useEffect — это синхронизация, а не жизненный цикл». И нельзя вызывать сеттер состояния: это триггер, а триггер посреди рендера — заявка на ещё один рендер.

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

Триггер во время рендера — новая заявка, и React выполняет её, не дожидаясь коммита: вызывает функцию снова, а она снова подаёт заявку. После двадцати пяти повторов React обрывает цикл.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

В консоли — «рендер 0» … «рендер 25», затем та же лестница ещё раз и ошибка. Повтор не случаен: наткнувшись на ошибку в рендере, React один раз пробует всё заново с чистого листа — вдруг сбой был временным, — и сдаётся только после второй неудачи. Текст самой ошибки песочница не покажет: её выбросил подключённый скрипт React, а такие тексты браузер прячет, — на её месте заглушка и приписка отладочной сборки о компоненте <Loop>. В консоли своего проекта вы прочтёте ошибку целиком: «Too many re-renders. React limits the number of renders to prevent an infinite loop». Коммита не было ни разу — #root так и остался пустым.

Главный инструмент этой главы — console.log первой строкой в теле компонента, с именем компонента и значением, ради которого он рисуется. Лог в обработчике считает триггеры, лог в таймере после клика показывает результат коммита, и только лог в теле считает рендеры. Но одну особенность стоит знать заранее: не всякий триггер доходит до рендера. Ниже три клика — сколько строк «рендер» они добавят?

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Первый клик по «Оставить как есть» не напечатал ничего: сеттер сравнил новое значение со старым через Object.is, увидел то же самое число и заявку не подал. А вот третий клик, точно такой же, строку добавил. React вправе так поступить: обещание звучит как «компонент и его дети не будут перерисованы», а на деле React иногда всё же вызывает саму функцию, чтобы убедиться, что состояние не изменилось, — и только потом бросает работу, не трогая ни детей, ни DOM. Лишняя строка в консоли — не ошибка в вашем коде, а ещё одна причина, по которой рендер должен быть чистым.

Отсюда важное следствие для объектов и массивов: Object.is сравнивает ссылки, и объект, изменённый на месте, — это «то же самое». Триггера не будет, как бы сильно вы ни поменяли его поля. Копия вместо изменения на месте — не стилистика, а единственный способ подать заявку; этому посвящена отдельная статья главы.

Где стоит console.logЧто он считает
Первой строкой в теле компонентаРендеры: по строке на каждый вызов функции, включая вызовы из-за родителя
В обработчике событияТриггеры: сколько раз попросили перерисовать, а не сколько раз перерисовали
В setTimeout после события, с чтением DOMРезультат коммита: что в итоге оказалось на экране

В песочнице учебника React стоит отладочной сборкой, но без <StrictMode>, поэтому каждый рендер печатает ровно одну строку. В проекте на Next.js или Vite при разработке компоненты обёрнуты в <StrictMode>, и React намеренно вызывает каждую функцию дважды, чтобы нечистый рендер выдал себя сразу — как счётчик гостей выше. Вторую строку React DevTools показывает приглушённой, но в консоли она есть. В боевой сборке двойного вызова нет; логи, которые вы там забыли, — есть.

Когда логов становится слишком много, их заменяет вкладка Profiler в React DevTools и галочка «Highlight updates when components render»: она подсвечивает на странице компоненты, чьи функции только что вызывались. Это тот же счётчик, только без строк в консоли.

  • Триггер — заявка: root.render() и сеттер состояния только записывают её и возвращают управление; компонент будет вызван позже, когда до заявки дойдёт React.
  • Рендер — вызов функции компонента и всех компонентов, которые он возвращает; пропсы перед вызовом не сравниваются. Родители и соседи не перерисовываются.
  • Коммит — единственный шаг, который трогает DOM: он идёт после всех вызовов и меняет только разницу между старым и новым описанием.
  • Во время рендера в DOM ещё старое состояние, новое появится после коммита — читайте страницу из таймера, а не из обработчика.
  • Рендер обязан быть чистым: React вправе вызвать функцию дважды, выбросить результат или сменить порядок. Не менять внешнее, не трогать DOM, не вызывать сеттеры.
  • Сеттер с тем же значением (по Object.is) рендера не запускает — поэтому объект, изменённый на месте, экран не обновит.

1/4Скрипт печатает «до», вызывает root.render(<App />) и печатает «после». В теле App первой строкой стоит console.log("рендер"). В каком порядке появятся строки?

2/4У Page есть состояние, внутри неё <Header title="Доска" /> и <Footer /> без пропсов. Состояние Page изменилось. Чьи функции вызовет React?

3/4В обработчике клика сразу после setCount(5) стоит console.log(document.getElementById("out")!.textContent). До клика на экране было 4. Что напечатается?

4/4Почему функция компонента обязана быть чистой?

Список перепутался

Компонент Sorted показывает названия по алфавиту, а App над ним — в порядке ввода. При первой отрисовке всё верно, но стоит нажать кнопку — и строка «как ввели» тоже становится отсортированной. Найдите нечистоту и почините её одним методом массива из части I.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

sort сортирует массив на месте, а items — это массив из состояния App, переданный пропсом. Рендер Sorted изменил то, что существовало до вызова. При первой отрисовке беда не видна: App успел собрать строку «как ввели» до того, как React вызвал Sorted. Но со второго рендера App читает уже испорченный массив.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

toSorted появился в ES2023; в старых окружениях то же самое даёт [...items].sort(). Пропсы и состояние в рендере только читают — любой метод, который меняет массив на месте, применяют к копии.

Посчитать до запуска

Не запуская пример, посчитайте, сколько строк «рендер» окажется в консоли после того, как сработают оба таймера. Запишите число, потом запустите и сверьте.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Девять. Первая отрисовка — четыре строки: Board, Header, Column todo, Column done. Клик по «todo» меняет состояние одной колонки — одна строка: перерисовывается она и только она, ни Board, ни соседняя колонка не вызываются. Клик по «Обновить доску» меняет состояние Board, и рендер идёт вниз по всему дереву — ещё четыре строки, включая Header, у которого нет ни пропсов, ни состояния.

Если насчитали меньше, проверьте два места: считали ли вы Header при перерисовке доски и не приписали ли соседней колонке рендер по клику на «todo».