useState: память компонента
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Компонент — это функция, и React зовёт её заново на каждую перерисовку. Обычная переменная внутри такой функции живёт ровно один вызов: значение в ней меняется, но на экране ничего не происходит, а следующая отрисовка возвращает её к начальному. useState даёт компоненту память, которую React держит у себя и возвращает при каждом вызове.
В проекте части II доска задач жила в обычном массиве, а страницу приводила в порядок функция render() — и звать её приходилось руками после каждого изменения. Забыли позвать — на экране осталось старое. React убирает ровно этот ручной вызов, но взамен требует, чтобы данные лежали в известном ему месте.
Начнём с того, как не работает. Компонент — обычная функция, и переменную внутри неё объявить никто не мешает. Предскажите вывод: пример нажимает кнопки за вас в таком порядке — «клик», «клик», «перерисовать», «клик».
Переменная честно менялась: 1, 2. На экране это не отразилось ни разу — React не следит за локальными переменными и не догадывается, что пора перерисовывать. А когда перерисовка всё-таки случилась по другой причине, функция Mixed была вызвана заново, строка let clicks = 0 выполнилась снова — и счёт пошёл с нуля.
Две беды сразу: значение не переживает перерисовку и не вызывает её. От состояния нужно и то и другое.
Вынести переменную наружу компонента — не выход: она переживёт перерисовку, но по-прежнему никого не разбудит. Это и есть ручной render() из части II, только без вызова.
useState зовут прямо в теле компонента. Он принимает начальное значение и возвращает пару: текущее значение и функцию, которая его меняет. Пару принято тут же разложить деструктуризацией — приём из части I.
Три строки «рисую» — это три вызова функции Counter: один при первой отрисовке и по одному на каждый клик. Главное в выводе — второй и третий: React.useState(0) отдаёт ноль только в первый раз, дальше он возвращает то, что лежит в памяти React.
Сеттер делает две вещи: кладёт новое значение в память и просит перерисовать компонент. Просит — не значит «делает немедленно»: строка «на кнопке» напечаталась последней, когда обе перерисовки уже прошли. Если прочитать текст кнопки сразу после клика, там окажется старое значение; почему так — тема двух следующих статей.
Хук возвращает массив, а не объект вида { value, setValue }, ради свободы имён: деструктуризация массива берёт значения по местам, поэтому в одном компоненте уживаются сколько угодно состояний. Имена принято строить парой x и setX — это не требование React, а всеобщий уговор.
Раз count — переменная, почему бы просто не присвоить ей новое значение? Компилятор отвечает на это раньше, чем браузер.
Пара приходит через const, и диагностика на строке 5 говорит именно об этом. Но даже let не спас бы: присвоение поменяло бы локальную переменную, которая живёт до конца вызова функции, а память лежит у React, и о вашем присвоении он не узнает. Единственная дверь внутрь — сеттер.
Где именно эта память? Не в имени переменной и не в имени функции: React хранит её для конкретного места компонента в дереве. Два одинаковых счётчика рядом ничего друг о друге не знают.
Два клика по первому, один по второму — и каждый считает своё. Тот же компонент, тот же useState(0), две независимые ячейки. Поэтому компонент со состоянием можно ставить на страницу сколько угодно раз, не боясь, что копии передерутся за общую переменную.
Тип состояния компилятор выводит из начального значения — механика та же, что в части III. Из 0 получается number, и сеттер сразу начинает требовать число.
Заодно видно, как называется тип аргумента сеттера: SetStateAction<number> — это «либо новое значение, либо функция, которая его посчитает». О второй половине речь пойдёт в статье про функцию-обновитель.
Вывод типов подводит там, где начальное значение беднее будущего. Пустой массив ничего не знает о своих элементах, null — о том, что когда-нибудь станет объектом, а строка расширяется до string, теряя набор допустимых значений.
| Начальное значение | Что выведет компилятор | Что писать |
|---|---|---|
0, false, "Рико" | number, boolean, string | ничего, вывода достаточно |
[] | never[] — массив, куда нельзя ничего положить | useState<Item[]>([]) |
null | null — и других значений не примет | useState<User | null>(null) |
"idle" при трёх допустимых | string — опечатка пройдёт молча | useState<Status>("idle") |
Первые два случая компилятор ловит сам, третий — самый коварный: ошибки нет, а состояние принимает любую строку. Запустите и сравните диагностики двух примеров: код в них отличается только типами.
В первом примере одна диагностика — про never, а опечатка "loadnig" прошла незамеченной. Во втором её ловит компилятор, и это ровно та причина, по которой юнион-состояния типизируют явно, даже когда лень.
Отдельный случай — данные, которые появятся позже: выбранная строка, ответ сервера, открытая карточка. Начальное значение тут null, и без явного типа состояние навсегда останется пустым.
Явный юнион чинит и типы, и чтение: user приходится сузить проверкой, прежде чем брать name, — сужение из части III работает здесь без единой поправки.
Начальное значение нужно React ровно однажды — при первой отрисовке. Но useState(readSaved()) — это вызов, и он случается при каждом вызове компонента, сколько бы раз тот ни перерисовался.
Три строки в выводе на три отрисовки. Значение при этом использовалось только первое: остальные два раза функция посчитала его зря. С разбором сохранённого черновика или чтением из localStorage такая работа на каждый клик уже заметна.
Лекарство — отдать useState саму функцию вместо результата. React зовёт её только при первой отрисовке; это называется ленивой инициализацией.
Разница ровно в двух символах: 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). По первому кликнули дважды. Что показывает второй?
Кнопка, которая не считает
Кнопка должна показывать число нажатий. Сейчас переменная растёт, а надпись стоит на месте. Почините счётчик, не трогая разметку.
Обычная переменная не переживает вызов компонента и не сообщает React, что пора перерисовывать. Меняем её на состояние: значение читаем из пары, изменяем сеттером.
Разметка осталась прежней — поменялось только то, откуда берётся count и как он меняется.
Типы для пустого состояния
Компонент не компилируется: компилятор вывел типы из начальных значений и теперь не даёт положить в состояние ничего осмысленного. Запустите, прочитайте, на что он жалуется, и добавьте явные типы.
Пустой массив выводится как never[], а null — как тип null: и туда, и туда компилятор не пускает никакие значения. Оба состояния описываем явно.
Тип элемента у нас уже есть — Item, — и оба состояния строятся из него: список это Item[], а выбранная запись — Item | null. Проверка current === null в разметке после этого не просто вежливость: без неё компилятор не даст взять title.
Считать один раз
Функция loadDraft изображает разбор сохранённого черновика — дорогую работу, которая нужна ровно однажды. Сейчас она выполняется на каждой отрисовке. Исправьте одним изменением.
Убираем скобки вызова: React примет функцию и позовёт её сам, только при первой отрисовке.
Проверка простая: после правки «разбираю черновик» печатается один раз, сколько бы кликов ни было. Если бы в состоянии должна была лежать сама функция, пришлось бы обернуть её стрелкой: useState(() => loadDraft).