Состояние и производные значения
Простыми словами
Как заводить состояние и вычислять из него другие значения — без хуков и массивов зависимостей.
Два способа завести состояние
state — способ по умолчанию: ячейка, которую читают как значение и меняют через .set() из обработчиков. State — реактивное значение; в FRP-литературе этот тип называется Behavior. Если знаете useState — это оно (минус перезапуски):
import { state } from "@continuum-js/frp";
const theme = state<"light" | "dark">("light");
<button onClick={() => theme.set("dark")}>тёмная</button>;stream + accum — потоковая форма, когда состояние по природе — свёртка случившегося:
import { stream } from "@continuum-js/frp";
const clicks = stream<MouseEvent>();
const count = clicks.accum(0, (_e, n) => n + 1);Правило на память: нет истории — state; история стоит того, чтобы её хранить — поток. Ячейка state — буквально значение плюс поток его обновлений (.updates), и это правильный инструмент для значений, которые просто перезаписывают: поля формы, тогглы, выбранная вкладка. Потоковая форма окупается, как только начинает значить история значения — много независимых источников изменений, undo, персист, синхронизация: один поток действий даёт каждую из этих фич как ещё одну свёртку, и он напрямую стыкуется с потоковыми инструментами вроде debounce и at.
Переходы состояния: .on
Между этими полюсами — самый частый случай: одна ячейка, которую меняют несколько потоков. Объявляйте переходы прямо на ней:
const count = state(0)
.on(inc, (n) => n + 1)
.on(dec, (n) => n - 1)
.on(reset, () => 0);Редьюсер получает (состояние, событие); вхождение события и обновление ячейки — один момент, так что семантика снимков сохраняется. .on регистрирует процесс в текущем скоупе: в компоненте он есть автоматически, на уровне модуля оберните определение в root(...) (см. ниже).
Производные значения — только map/combine
Всё, что можно вычислить из существующих States, не должно храниться:
import { combine } from "@continuum-js/frp";
const total = items.map((xs) => xs.reduce((s, i) => s + i.price, 0));
const label = combine(total, currency, (t, cur) => `${t.toFixed(2)} ${cur}`);Анти-паттерн — «вычислить и положить сеттером»:
// ПЛОХО: total теперь нужно не забывать обновлять при каждом изменении items
const total = state(0);
itemsChanged.listen(() => total.set(computeTotal()));Это ручная синхронизация — ровно то, от чего FRP избавляет. Правило: если значение выводимо — оно map; state только для первичных фактов.
Чтение текущего значения
- В JSX State кладётся как есть —
{count}— это привязка. - В обработчике события используйте
at, а неsample:
const submitted = draft.at(submitClicks);b.sample() существует и честно возвращает текущее значение всегда — даже у спящей формулы, — но у него нет семантики одновременности: два одновременных события увидят «какое-то» значение в зависимости от порядка. at определён точно: значение до текущего момента. Практическое правило: sample — для кода вне сети (инициализация, тесты, интеграции), at — внутри неё.
Подавление лишних обновлений
Сама ячейка уже коалесцирует: на один момент она доставляет одно вхождение updates — несколько set в одном batch дают подписчикам только финальное значение. dedupe из @continuum-js/std дополняет это для производных: убирает подряд идущие равные значения — полезно перед dyn/Show, чтобы не перестраивать поддерево зря:
import { dedupe } from "@continuum-js/std";
const page = dedupe(location().map((u) => u.pathname.split("/")[1]));Состояние вне компонентов
Правило Continuum: значения — формулы; состояние и эффекты принадлежат скоупу.
Чистые производные (map, combine, filter) — формулы: рецепты, которые подключаются к источнику по первому слушателю, засыпают без слушателей, а sample() отвечает всегда. Владелец им не нужен — формулу можно свободно объявить на уровне модуля и импортировать:
// формула: шарится свободно, живёт по требованию
export const page = location().map((u) => u.pathname);Ячейки-источники (state) — тоже обычные объекты без собственных подписок: их можно объявлять на уровне модуля и импортировать. Так устроен location() в роутере — синглтон-State на всю страницу.
А вот стейтовые производные (hold, accum, .on) и эффекты (perform, resource) — процессы: их значение зависит от каждого вхождения, поэтому им нужен владелец. Внутри компонента скоуп есть автоматически; на уровне модуля объявите время жизни явно через root() — иначе рантайм остановит вас обучающей ошибкой:
import { root } from "@continuum-js/frp";
export const count = root(() => clicks.accum(0, (_e, n) => n + 1));Локальное состояние компонента живёт в его теле и умирает вместе с поддеревом: когда скоуп уничтожается, его hold/accum замирают на последнем значении (см. Владение и жизненный цикл). А retain() — чисто перф-подсказка: держать горячую разделяемую формулу подключённой при текучке слушателей вместо засыпания и пробуждения; для корректности он не нужен никогда.
Незнакомый термин? Вся терминология этой документации объяснена в глоссарии.
Выделение в списках: selector()
Производная «выбрана ли эта строка?» на каждую строку (selected.map((s) => s === id)) создаёт узел на строку и пересчитывает их все при каждом клике. selector() переворачивает картину: ОДИН процесс следит за значением, каждый ключ получает крошечную ячейку, и смена выделения переключает ровно две ячейки — в одном моменте:
import { selector } from "@continuum-js/frp";
const rowClass = selector(selected, "danger", ""); // форма со значениями
// в строке: <tr class={rowClass(row.id)}> — ноль лишних узлов на строкуСледящий процесс принадлежит ambient-скоупу (компоненту или root() на уровне модуля).