useState: память компонента

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

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

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

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

В проекте части II доска задач жила в обычном массиве, а страницу приводила в порядок функция render() — и звать её приходилось руками после каждого изменения. Забыли позвать — на экране осталось старое. React убирает ровно этот ручной вызов, но взамен требует, чтобы данные лежали в известном ему месте.

Начнём с того, как не работает. Компонент — обычная функция, и переменную внутри неё объявить никто не мешает. Предскажите вывод: пример нажимает кнопки за вас в таком порядке — «клик», «клик», «перерисовать», «клик».

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

Переменная честно менялась: 1, 2. На экране это не отразилось ни разу — React не следит за локальными переменными и не догадывается, что пора перерисовывать. А когда перерисовка всё-таки случилась по другой причине, функция Mixed была вызвана заново, строка let clicks = 0 выполнилась снова — и счёт пошёл с нуля.

Две беды сразу: значение не переживает перерисовку и не вызывает её. От состояния нужно и то и другое.

Вынести переменную наружу компонента — не выход: она переживёт перерисовку, но по-прежнему никого не разбудит. Это и есть ручной render() из части II, только без вызова.

useState зовут прямо в теле компонента. Он принимает начальное значение и возвращает пару: текущее значение и функцию, которая его меняет. Пару принято тут же разложить деструктуризацией — приём из части I.

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

Три строки «рисую» — это три вызова функции Counter: один при первой отрисовке и по одному на каждый клик. Главное в выводе — второй и третий: React.useState(0) отдаёт ноль только в первый раз, дальше он возвращает то, что лежит в памяти React.

Сеттер делает две вещи: кладёт новое значение в память и просит перерисовать компонент. Просит — не значит «делает немедленно»: строка «на кнопке» напечаталась последней, когда обе перерисовки уже прошли. Если прочитать текст кнопки сразу после клика, там окажется старое значение; почему так — тема двух следующих статей.

Хук возвращает массив, а не объект вида { value, setValue }, ради свободы имён: деструктуризация массива берёт значения по местам, поэтому в одном компоненте уживаются сколько угодно состояний. Имена принято строить парой x и setX — это не требование React, а всеобщий уговор.

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

Раз count — переменная, почему бы просто не присвоить ей новое значение? Компилятор отвечает на это раньше, чем браузер.

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

Пара приходит через const, и диагностика на строке 5 говорит именно об этом. Но даже let не спас бы: присвоение поменяло бы локальную переменную, которая живёт до конца вызова функции, а память лежит у React, и о вашем присвоении он не узнает. Единственная дверь внутрь — сеттер.

Где именно эта память? Не в имени переменной и не в имени функции: React хранит её для конкретного места компонента в дереве. Два одинаковых счётчика рядом ничего друг о друге не знают.

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

Два клика по первому, один по второму — и каждый считает своё. Тот же компонент, тот же useState(0), две независимые ячейки. Поэтому компонент со состоянием можно ставить на страницу сколько угодно раз, не боясь, что копии передерутся за общую переменную.

Тип состояния компилятор выводит из начального значения — механика та же, что в части III. Из 0 получается number, и сеттер сразу начинает требовать число.

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

Заодно видно, как называется тип аргумента сеттера: SetStateAction<number> — это «либо новое значение, либо функция, которая его посчитает». О второй половине речь пойдёт в статье про функцию-обновитель.

Вывод типов подводит там, где начальное значение беднее будущего. Пустой массив ничего не знает о своих элементах, null — о том, что когда-нибудь станет объектом, а строка расширяется до string, теряя набор допустимых значений.

Начальное значениеЧто выведет компиляторЧто писать
0, false, "Рико"number, boolean, stringничего, вывода достаточно
[]never[] — массив, куда нельзя ничего положитьuseState<Item[]>([])
nullnull — и других значений не приметuseState<User | null>(null)
"idle" при трёх допустимыхstring — опечатка пройдёт молчаuseState<Status>("idle")

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

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

В первом примере одна диагностика — про never, а опечатка "loadnig" прошла незамеченной. Во втором её ловит компилятор, и это ровно та причина, по которой юнион-состояния типизируют явно, даже когда лень.

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

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

Явный юнион чинит и типы, и чтение: user приходится сузить проверкой, прежде чем брать name, — сужение из части III работает здесь без единой поправки.

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

Начальное значение нужно React ровно однажды — при первой отрисовке. Но useState(readSaved()) — это вызов, и он случается при каждом вызове компонента, сколько бы раз тот ни перерисовался.

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

Три строки в выводе на три отрисовки. Значение при этом использовалось только первое: остальные два раза функция посчитала его зря. С разбором сохранённого черновика или чтением из localStorage такая работа на каждый клик уже заметна.

Лекарство — отдать useState саму функцию вместо результата. React зовёт её только при первой отрисовке; это называется ленивой инициализацией.

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

Разница ровно в двух символах: readSaved() — вызвать и отдать результат, readSaved — отдать функцию.

Обратная сторона: если само состояние должно быть функцией, useState(fn) её выполнит, а не сохранит. Пишите useState(() => fn) — React позовёт внешнюю стрелку и сохранит то, что она вернула.

  • Компонент — функция, и перерисовка вызывает её заново: локальная переменная не переживает вызов и не запускает перерисовку.
  • useState(начальное) возвращает пару: текущее значение и сеттер, который кладёт новое значение и просит перерисовать.
  • Присваивать значению нельзя: память лежит в React, привязана к месту компонента в дереве, и попасть в неё можно только сеттером.
  • Тип выводится из начального значения; для [], null и литеральных юнионов его пишут явно — иначе состояние либо не примет ничего, либо примет что угодно.
  • Аргумент useState вычисляется на каждой отрисовке: дорогое начальное значение передают функцией, а не результатом.

1/4В компоненте объявлена обычная переменная let clicks = 0. Три клика увеличили её до 3, на экране по-прежнему 0. Затем соседняя кнопка вызвала сеттер состояния, компонент перерисовался. Что напечатает следующий клик?

2/4Написано const [status, setStatus] = React.useState("idle"), а допустимых значений три: idle, loading, done. Какой тип у status и что будет с опечаткой setStatus("loadnig")?

3/4В компоненте написано React.useState(readSaved()), а readSaved печатает строку в консоль. Компонент отрисовался и дважды перерисовался по клику. Сколько строк будет в выводе?

4/4На странице стоят два одинаковых <Counter />, внутри у каждого useState(0). По первому кликнули дважды. Что показывает второй?

Кнопка, которая не считает

Кнопка должна показывать число нажатий. Сейчас переменная растёт, а надпись стоит на месте. Почините счётчик, не трогая разметку.

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

Обычная переменная не переживает вызов компонента и не сообщает React, что пора перерисовывать. Меняем её на состояние: значение читаем из пары, изменяем сеттером.

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

Разметка осталась прежней — поменялось только то, откуда берётся count и как он меняется.

Типы для пустого состояния

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

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

Пустой массив выводится как never[], а null — как тип null: и туда, и туда компилятор не пускает никакие значения. Оба состояния описываем явно.

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

Тип элемента у нас уже есть — Item, — и оба состояния строятся из него: список это Item[], а выбранная запись — Item | null. Проверка current === null в разметке после этого не просто вежливость: без неё компилятор не даст взять title.

Считать один раз

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

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

Убираем скобки вызова: React примет функцию и позовёт её сам, только при первой отрисовке.

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

Проверка простая: после правки «разбираю черновик» печатается один раз, сколько бы кликов ни было. Если бы в состоянии должна была лежать сама функция, пришлось бы обернуть её стрелкой: useState(() => loadDraft).