Skip to content

Состояние и производные значения

Простыми словами

Как заводить состояние и вычислять из него другие значения — без хуков и массивов зависимостей.

Два способа завести состояние

state — способ по умолчанию: ячейка, которую читают как значение и меняют через .set() из обработчиков. State — реактивное значение; в FRP-литературе этот тип называется Behavior. Если знаете useState — это оно (минус перезапуски):

tsx
import { state } from "@continuum-js/frp";

const theme = state<"light" | "dark">("light");
<button onClick={() => theme.set("dark")}>тёмная</button>;

stream + accum — потоковая форма, когда состояние по природе — свёртка случившегося:

ts
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

Между этими полюсами — самый частый случай: одна ячейка, которую меняют несколько потоков. Объявляйте переходы прямо на ней:

ts
const count = state(0)
  .on(inc, (n) => n + 1)
  .on(dec, (n) => n - 1)
  .on(reset, () => 0);

Редьюсер получает (состояние, событие); вхождение события и обновление ячейки — один момент, так что семантика снимков сохраняется. .on регистрирует процесс в текущем скоупе: в компоненте он есть автоматически, на уровне модуля оберните определение в root(...) (см. ниже).

Производные значения — только map/combine

Всё, что можно вычислить из существующих States, не должно храниться:

ts
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}`);

Анти-паттерн — «вычислить и положить сеттером»:

ts
// ПЛОХО: total теперь нужно не забывать обновлять при каждом изменении items
const total = state(0);
itemsChanged.listen(() => total.set(computeTotal()));

Это ручная синхронизация — ровно то, от чего FRP избавляет. Правило: если значение выводимо — оно map; state только для первичных фактов.

Чтение текущего значения

  • В JSX State кладётся как есть — {count} — это привязка.
  • В обработчике события используйте at, а не sample:
ts
const submitted = draft.at(submitClicks);

b.sample() существует и честно возвращает текущее значение всегда — даже у спящей формулы, — но у него нет семантики одновременности: два одновременных события увидят «какое-то» значение в зависимости от порядка. at определён точно: значение до текущего момента. Практическое правило: sample — для кода вне сети (инициализация, тесты, интеграции), at — внутри неё.

Подавление лишних обновлений

Сама ячейка уже коалесцирует: на один момент она доставляет одно вхождение updates — несколько set в одном batch дают подписчикам только финальное значение. dedupe из @continuum-js/std дополняет это для производных: убирает подряд идущие равные значения — полезно перед dyn/Show, чтобы не перестраивать поддерево зря:

ts
import { dedupe } from "@continuum-js/std";

const page = dedupe(location().map((u) => u.pathname.split("/")[1]));

Состояние вне компонентов

Правило Continuum: значения — формулы; состояние и эффекты принадлежат скоупу.

Чистые производные (map, combine, filter) — формулы: рецепты, которые подключаются к источнику по первому слушателю, засыпают без слушателей, а sample() отвечает всегда. Владелец им не нужен — формулу можно свободно объявить на уровне модуля и импортировать:

ts
// формула: шарится свободно, живёт по требованию
export const page = location().map((u) => u.pathname);

Ячейки-источники (state) — тоже обычные объекты без собственных подписок: их можно объявлять на уровне модуля и импортировать. Так устроен location() в роутере — синглтон-State на всю страницу.

А вот стейтовые производные (hold, accum, .on) и эффекты (perform, resource) — процессы: их значение зависит от каждого вхождения, поэтому им нужен владелец. Внутри компонента скоуп есть автоматически; на уровне модуля объявите время жизни явно через root() — иначе рантайм остановит вас обучающей ошибкой:

ts
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() переворачивает картину: ОДИН процесс следит за значением, каждый ключ получает крошечную ячейку, и смена выделения переключает ровно две ячейки — в одном моменте:

ts
import { selector } from "@continuum-js/frp";

const rowClass = selector(selected, "danger", ""); // форма со значениями
// в строке: <tr class={rowClass(row.id)}> — ноль лишних узлов на строку

Следящий процесс принадлежит ambient-скоупу (компоненту или root() на уровне модуля).