Состояние и эффекты: интерфейс с памятью
- Зачем это нужно
- Состояние против обычной переменной
- Три вызова подряд
- Поле формы, привязанное к состоянию
- Поле, в котором ничего не набирается
- Где должно жить состояние
- Эффекты: выход во внешний мир
- Порядок выполнения
- Список зависимостей
- Типичные ошибки
- Слова главы
- Тренажёр: набрать вслепую
- На чемпионате
- Куда углубиться
- Куда дальше: проверенные ресурсы
Зачем это нужно
Компонент из прошлой главы — чистое вычисление: данные вошли, описание вышло. Такой интерфейс ничего не помнит.
Настоящий интерфейс помнит многое: что набрано в поле, какая вкладка открыта, идёт ли сейчас транзакция, что ответил узел. И умеет то, чего вычисление не умеет: делать что-то во внешнем мире — запрашивать баланс, подписываться на события контракта, ставить таймер.
Для первого есть состояние, для второго — эффекты. Обе темы просты в записи и полны ловушек в поведении, поэтому здесь всё разобрано живыми прогонами.
Состояние против обычной переменной
function Counter() {
const [n, setN] = useState(0);
return <p onClick={() => setN(n + 1)}>{n}</p>;
}
Прогон сравнивает состояние с обычной переменной, которая увеличивается в том же обработчике:
── состояние против обычной переменной ──
нажали дважды: на экране 2, обычная переменная 2
обычная тоже считает — но экран о ней не узнаёт, пока что-то не перерисуется
Обычная переменная считает верно. Разница в другом: изменение состояния заставляет компонент пересчитаться, а изменение обычной переменной — нет. На экране она появится случайно, когда пересчёт случится по другой причине.
Два признака того, что значение должно быть состоянием: его видно на экране и оно меняется во время работы. Всё остальное — обычные переменные и константы. Складывать в состояние то, что можно вычислить из других данных, — самая частая причина рассинхрона интерфейса.
Три вызова подряд
── три setN(n + 1) подряд в одном обработчике ──
было 2, стало 3 — ожидали +3
── три setN(v => v + 1) подряд ──
было 3, стало 6
Причина в том, что n внутри обработчика — значение того пересчёта, в котором обработчик был создан. Три раза подряд написано «поставь 3», и результат — 3.
Вторая форма передаёт функцию: «возьми то, что есть сейчас, и прибавь». Каждая из трёх применяется к результату предыдущей.
Правило простое: новое значение зависит от старого — пишите функцию. Не зависит (например, ставите значение из поля) — можно значением.
То же самое спасает в местах, где между созданием обработчика и его выполнением проходит время: таймеры, ответы из сети, подписки на события контракта. Там значение из пересчёта устаревает почти всегда.
Поле формы, привязанное к состоянию
const [amount, setAmount] = useState("");
<input value={amount} onChange={(e) => setAmount(e.target.value)} />
<button disabled={amount === ""}>Отправить</button>
<p>в состоянии: "{amount}"</p>
── поле, привязанное к состоянию ──
кнопка выключена при пустом поле: true
набрали 2.5 → в состоянии "2.5", на экране в состоянии: "2.5"
кнопка теперь: включена
Разница с ручным подходом принципиальная. Раньше значение жило в поле, и всё остальное — кнопка, подсказка, проверка — приходилось обновлять отдельно, вручную, не забывая ни одного места.
Здесь значение живёт в состоянии, а поле — лишь один из его читателей. Прогон это показал буквально: два поля, смотрящих в одно состояние, менялись одновременно.
Поле, в котором ничего не набирается
Самая известная ловушка этого подхода:
── value есть, обработчика изменения нет ──
пытались набрать 999 → в поле осталось "5"
поле выглядит сломанным: буквы не появляются
Если задать полю значение из состояния, но не задать обработчик изменения, поле замирает. Пользователь жмёт клавиши — ничего не происходит.
Логика такая: значение поля полностью определяется состоянием. Нажатие клавиши состояние не меняет (обработчика нет) — значит, и поле остаётся прежним.
Три выхода:
- обычный случай — добавить обработчик, как выше;
- поле только для чтения — так и пометить: тогда понятно, что так задумано;
- поле со свободным вводом — задать только начальное значение. Тогда поле живёт само, но и состояние про него ничего не знает.
── поле со свободным вводом ──
в поле: "что угодно", состояние про это ничего не знает
Где должно жить состояние
Если одно значение нужно двум компонентам, оно живёт у их общего родителя, а вниз уходит свойствами. Это то самое «данные вниз, события вверх» из прошлой главы.
function Vault() {
const [amount, setAmount] = useState("");
return (
<>
<DepositForm amount={amount} onChange={setAmount} />
<Preview amount={amount} />
</>
);
}
Форма показывает значение и сообщает об изменениях; предпросмотр только читает. Ни один из них не владеет значением — владеет родитель.
Обратное правило тоже важно: если состояние нужно одному компоненту, оно живёт в нём. Поднимать всё наверх «на всякий случай» — верный способ получить один компонент на пятьсот строк, который пересчитывается от любого чиха.
Эффекты: выход во внешний мир
Компонент обязан быть вычислением. Но интерфейсу нужно запрашивать баланс, подписываться на события контракта, ставить таймеры. Для этого есть отдельное место:
useEffect(() => {
let cancelled = false;
provider.getBalance(address).then((v) => {
if (!cancelled) setBalance(v);
});
return () => { cancelled = true; };
}, [address]);
Порядок выполнения
Прогон с тремя эффектами печатает, что и когда сработало:
── появление компонента ──
тело компонента, n = 0
эффект без списка зависимостей, n = 0
эффект с пустым списком — только при появлении
эффект со списком [n], n = 0
── состояние изменилось на 1 ──
тело компонента, n = 1
уборка за эффектом [n], n был 0
эффект без списка зависимостей, n = 1
эффект со списком [n], n = 1
── компонент убрали со страницы ──
уборка за эффектом [n], n был 1
Три наблюдения, каждое из которых потом экономит час отладки.
Тело компонента всегда первым. Эффект выполняется после того, как экран обновлён. Поэтому в эффекте можно измерить элемент — он уже на странице.
Эффект с пустым списком при изменении состояния не сработал. Только при появлении — как и обещано.
Уборка идёт перед новым запуском. В логе она стоит между телом и новым эффектом, и в ней видно старое значение: 0, когда новое уже 1. Так и должно быть — это уборка за предыдущим.
При уходе компонента — только уборка. Место, где снимают подписки. Не снять их — значит оставить работающий таймер или слушателя событий контракта, который пишет в состояние несуществующего компонента.
В режиме разработки эффекты специально запускаются дважды — появление, уборка, появление. Это не поломка, а проверка: если эффект не переживает повторного запуска, значит, уборка написана неверно, и в настоящей работе это когда-нибудь всплывёт.
Список зависимостей
Три варианта, и путать их дорого:
| запись | когда выполняется |
|---|---|
| списка нет | после каждого пересчёта |
[] | один раз при появлении |
[a, b] | при появлении и когда изменилось a или b |
Главная ловушка — неполный список. Если внутри эффекта используется значение, которого нет в списке, эффект будет работать со старым: подпишется на прошлый адрес, запросит баланс не того кошелька. Ошибка молчаливая и очень неприятная.
Обратная ловушка — лишнее в списке. Объект или функция, созданные заново при каждом пересчёте, каждый раз «новые», и эффект запускается бесконечно.
Практическое правило: включайте в список всё, что читаете внутри эффекта. Если из-за этого эффект стал перезапускаться слишком часто — проблема не в списке, а в том, что значение пересоздаётся зря.
Типичные ошибки
Изменение состояния прямо в теле компонента. Пересчёт → изменение → пересчёт → бесконечный цикл. Изменение состояния бывает только в обработчике или в эффекте.
Изменение массива или объекта на месте. Добавили элемент в существующий массив — ссылка та же, библиотека не видит изменения, экран не обновился. Создают новый: копия плюс новое.
setN(n + 1) несколько раз подряд. Разобрано выше.
Поле с value без обработчика. Замирает.
Запрос в сеть в теле компонента. Уйдёт при каждом пересчёте.
Неполный список зависимостей. Эффект работает со старыми значениями.
Забытая уборка. Подписка на события контракта остаётся жить после ухода компонента и пишет в никуда.
Состояние хранит вычислимое. Держат сумму и список, из которого она считается, — и однажды они расходятся.
Слова главы
- Состояние — значение, которое компонент помнит между пересчётами; его изменение вызывает пересчёт.
- Функция изменения — не присваивание: текущая переменная в этом вызове не меняется.
- Форма с функцией —
set(v => …): берёт актуальное значение на момент применения. - Поле, привязанное к состоянию — значение из состояния плюс обработчик; без обработчика замирает.
- Подъём состояния — общее значение живёт у общего родителя.
- Эффект — код, выполняемый после отрисовки; место для выхода во внешний мир.
- Уборка — снять подписку, таймер, отменить запрос; идёт перед новым запуском и при уходе.
- Список зависимостей — что должно измениться, чтобы эффект повторился.
Тренажёр: набрать вслепую
Цель: скорость ≥ 95 зн/мин, точность ≥ 88%
const [amount, setAmount] = useState("");
На чемпионате
Состояние транзакции — обязательное. Не «отправляем» и «готово», а четыре положения: покой, подписываем, ждём подтверждения, готово или отказ. Пользователь должен видеть, на каком шаге всё встало.
Кнопка выключается на время операции. Одна строка: выключена, пока состояние не в покое. Иначе три нажатия — три транзакции.
Отписка в уборке — сразу, а не потом. Подписка на события контракта, оставленная после ухода компонента, — самый неприятный вид ошибки: он проявляется через десять минут работы и не воспроизводится по требованию.
Флаг отмены в каждом запросе. Сеть отвечает секунды, пользователь за это время успевает переключить вкладку или сменить адрес.
И общий совет: держите состояние как можно ниже. Одно большое хранилище на весь интерфейс кажется удобным ровно до того момента, когда любое нажатие начинает пересчитывать всё.
Куда углубиться
- Сложное состояние одним обработчиком — когда переходов много и они связаны.
- Общее значение без передачи через все уровни — способ раздать тему или адрес кошелька вглубь.
- Ссылка на значение без пересчёта — то, что надо помнить, но не показывать.
- Свой набор действий — вынести работу с контрактом в отдельную функцию, чтобы переиспользовать.
- Ссылка на элемент страницы — когда всё-таки нужен доступ к настоящему узлу.
Сводная проверка по всей главе: 8 вопросов, 5:00 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.
Куда дальше: проверенные ресурсы
- React: добавляем интерактивность — состояние, события, пакетирование изменений.
- React: управление состоянием — подъём состояния и его структура.
- React: useState — справочник, включая форму с функцией.
- React: useEffect — зависимости, уборка, двойной запуск в разработке.
- React: возможно, вам не нужен эффект — про то, что чаще всего пишут зря.
Комментарии
Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.