State Management
Производное состояние
Экран показывает только активные задачи и их число. Разработчик заводит поле activeTasks, рядом activeCount, и обновляет оба при каждом действии: добавил задачу, выполнил, удалил, сменил фильтр. Полтора десятка мест, где легко забыть пересчёт. Стоит понять, что и список активных, и их число это просто функция от полного списка задач и текущего фильтра - и поля исчезают. Остаётся один источник (все задачи плюс фильтр), а активные и их количество выводятся из него на каждом рендере.
- Список активных задач из полного списка и текущего фильтра
- Число непрочитанных уведомлений как количество элементов с флагом unread
- Итоговая цена со скидкой из позиций корзины и промокода
- Отсортированная и отфильтрованная таблица из исходных строк и параметров вида
- Признак валидности формы как результат проверки всех полей, а не отдельный флаг
Предварительные знания
- Принцип единого источника истины из предыдущего урока
- Идея хранить входы, выводить результаты
- Базовое знакомство с функциями высшего порядка (map, filter, reduce)
Не хранить вычислимое
Производное состояние это любое значение, которое можно получить из уже имеющегося состояния чистой функцией. Список активных задач выводится из всех задач и фильтра. Число непрочитанных выводится из списка уведомлений. Раз значение определяется входами однозначно, хранить его как отдельное поле - значит снова создавать дубль и обязанность синхронизировать. Вместо этого его вычисляют там, где оно нужно.
Теперь добавление, выполнение или удаление задачи меняет только tasks. Активный список и его число пересчитываются сами при следующем чтении, потому что они производные. Полей для ручной синхронизации не осталось, и рассинхрон между числом и списком стал невозможен в принципе.
Производное состояние всегда вычисляется чистой функцией от входов: одни и те же входы дают один и тот же результат, без побочных эффектов. Это то, что делает его безопасным для пересчёта в любой момент.
Почему список активных задач не стоит хранить отдельным полем рядом с полным списком?
Селекторы и computed-значения
Селектор это функция, принимающая состояние и возвращающая нужный из него кусок. Селекторы бывают тривиальные (взять одно поле) и производные (вычислить новое значение из нескольких полей). Они дают единое место для логики вывода: компонент не считает сам, а спрашивает у селектора. Если правило вычисления меняется, его правят в одном месте, а не во всех потребителях.
В разных экосистемах та же идея называется по-разному. В Redux это селекторы, в Zustand - функции-аргументы хука стора, в Vue и MobX - computed-значения, в Jotai - производные атомы. Суть одна: объявить значение как функцию от другого состояния, а не как отдельно хранимое поле.
- Селектор (производное значение) — selectTotal выводит сумму из позиций и скидки. Меняется позиция - total автоматически другой при следующем чтении. Хранить нечего, синхронизировать нечего.
- Хранимое поле total — total лежит в сторе и требует ручного пересчёта при любой правке позиций или скидки. Забыли пересчитать - показали устаревшую сумму.
Селекторы удобно держать рядом с определением стора и переиспользовать. Это и единое место правды для логики вывода, и точка, куда позже добавляется мемоизация, если вычисление окажется дорогим.
Что такое селектор в контексте производного состояния?
Мемоизация: когда она оправдана
Вычисление производного значения на каждом рендере обычно дёшево. Но иногда оно дорогое (сортировка тысяч строк, тяжёлая фильтрация) или возвращает новый объект или массив, из-за чего потребитель ре-рендерится зря - ведь новая ссылка считается изменением, даже если содержимое то же. Мемоизация решает оба случая: она кэширует результат и пересчитывает его только при изменении входов.
Пока rows и sortKey те же самые, мемоизированный селектор отдаёт ту же ссылку на отсортированный массив, не сортируя заново. Это экономит и время на тяжёлую операцию, и лишние ре-рендеры от новой ссылки. Как только хоть один вход изменился, результат пересчитывается и кэш обновляется.
| Ситуация | Мемоизация | Почему |
|---|---|---|
| Чтение одного поля примитива | Не нужна | Сравнение по значению, вычисления нет |
| Сумма по короткому массиву | Обычно не нужна | Дёшево пересчитать на каждом рендере |
| Сортировка/фильтр больших списков | Оправдана | Дорогая операция, выгодно кэшировать |
| Селектор возвращает новый объект/массив | Оправдана | Стабилизирует ссылку, убирает лишние ре-рендеры |
Мемоизация не бесплатна: она хранит прошлые входы и результат и добавляет сравнение. Оборачивать в неё дешёвое вычисление - усложнение без выгоды. Разумный порядок: сначала писать простой селектор, и добавлять мемоизацию, когда профайлер или новая ссылка действительно создают проблему.
В каком случае мемоизация селектора оправдана?
Связь с другими темами
Производное состояние реализуется через механизмы, которые встречаются в каждом сторе:
- Селекторы Zustand — Селектор это функция, выводящая нужный кусок из стора, в том числе производный
- computed в Pinia — Геттеры-вычисления в Pinia это та же идея производного значения с кэшированием
Итог
- Производное состояние это значение, полностью определяемое другим состоянием, поэтому его вычисляют, а не хранят
- Селектор это функция (state) => результат, которая выводит нужный кусок, в том числе производный
- Вычисление на каждом рендере убирает целый класс багов рассинхрона: производное не может разойтись с источником
- Мемоизация кэширует результат и пересчитывает его только при изменении входов селектора
- Мемоизация оправдана для дорогих вычислений (сортировка, фильтрация больших списков) или когда результат - новая ссылка
- Преждевременная мемоизация дешёвых вычислений добавляет сложность без выгоды: сперва измерить, потом кэшировать
Связанные уроки
- sm-03-single-source-of-truth — Производное состояние это практическое следствие правила хранить только неустранимое
- rc-38-zustand-state — Селекторы Zustand это и есть способ читать производные значения без лишних ре-рендеров
- vue-27-pinia-intro — computed-геттеры в Pinia это та же идея производного состояния в другой экосистеме