Skip to content

Мышление в States и Streams

Машинное отделение

Эта страница не нужна, чтобы строить на Continuum — для повседневной работы хватает Понятий. Читайте её, когда захотите понять, почему фреймворк может обещать «без багов обновления»: модель под капотом — классический FRP, и она по-настоящему элегантна.

Continuum — фреймворк классического FRP. Это не «реактивность» в смысле сигналов или прокси-обёрток: это две точно определённые абстракции времени и жёсткие гарантии их композиции. Туториал учит думать в этой модели; операционные детали (ранги, фазы транзакции, устройство flatten) разобраны в FRP-MODEL.

Проблема: состояние как синхронизация

В обработчико-центричном коде состояние — это переменные, которые обработчики синхронизируют между собой:

ts
let count = 0;
button.addEventListener("click", () => {
  count++;
  label.textContent = String(count); // не забыть!
  submit.disabled = count === 0; //     не забыть и это!
});

Каждое новое место, зависящее от count, — ещё одна строчка в каждом обработчике, который его меняет. Забыли одну — интерфейс рассинхронизировался. React решает это перезапуском: «поменялось состояние — перерисуем всё поддерево и сравним». Continuum решает иначе: зависимость объявляется один раз, при построении, и дальше поддерживается сетью.

Два типа времени

Вся модель — два типа:

  • State<A> (в FRP-литературе — Behavior) — значение, которое существует в каждый момент времени. Денотационно Time → A: у него нельзя спросить «пришло ли», у него можно только спросить «какое сейчас». Позиция мыши, текст поля ввода, текущий счётчик, URL.
  • Stream<A>дискретные вхождения: в какие-то моменты происходит что-то со значением A, в остальные — не происходит ничего. Клик, нажатие клавиши, ответ сервера, тик таймера.

Первый вопрос при проектировании любой фичи: это значение или событие?

Спрашиваете себяТип
«Что сейчас введено / выбрано / загружено?»State
«Произошёл ли клик / сабмит / ответ?»Stream

Ошибка в выборе всегда мстит: событие, засунутое в State («флажок justClicked»), требует ручного сброса; значение, размазанное по событиям, требует ручной синхронизации — мы вернулись к тому, от чего уходили.

Сеть строится, а не выполняется

Компонент в Continuum — обычная функция, которая выполняется один раз и строит две вещи: FRP-сеть и привязанный к ней DOM.

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

function Counter() {
  const clicks = stream<MouseEvent>();
  const count = clicks.accum(0, (_e, n) => n + 1);
  const parity = count.map((n) => (n % 2 === 0 ? "чёт" : "нечет"));
  return (
    <button onClick={clicks.fire}>
      count: {count} ({parity})
    </button>
  );
}

Прочитайте это не как «код, который выполнится при клике», а как схему: clicks → accum → count → map → parity, и два текстовых узла, подключённых к count и parity. Клик прокатывается по схеме; функция Counter больше не вызывается никогда. (Состояние — accum — привязано к скоупу компонента и умрёт вместе с ним; чистые производные вроде parity — формулы, живущие по спросу.)

Зависимости здесь не перечисляются в массиве — они и есть структура выражения. parity построен из count, значит зависит от него. Забыть зависимость синтаксически невозможно.

Несколько источников комбинируются явно — данные вперёд, функция последней:

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

const total = combine(priceW, qtyW, (price, qty) => price * qty);

Момент времени: одновременность честная

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

ts
const doubled = count.map((n) => n * 2);
const squared = count.map((n) => n * n);
const sum = combine(doubled, squared, (d, s) => d + s);
// при count: 2 → 3 sum перейдёт 8 → 15 одним шагом,
// промежуточного «6 + 4» не существует ни в один момент

Это и называется «отсутствие глитчей»: полуобновлённое состояние не просто редко наблюдается — его не существует в модели.

Два события в одном моменте — тоже честная одновременность. Stream.merge принимает функцию коалесинга и сворачивает одновременные вхождения в одно:

ts
const either = Stream.merge(left, right, (a, b) => a + b);

Почему hold задержан: прошлое доступно, настоящее — нет

e.hold(init) превращает событие в State: «последнее значение e». Ключевое правило: State обновляется на границе момента. Внутри того же момента, в котором пришло событие, hold ещё показывает старое значение.

Зачем так? Чтобы «текущее значение» было определено всегда, даже когда вы смотрите на него из события, которое его же и меняет:

ts
const submit = stream<void>();
const draft = input.hold(""); // текст поля

// какой текст отправляем? тот, что был НА МОМЕНТ сабмита:
const submitted = draft.at(submit);

Если бы hold обновлялся мгновенно, ответ на вопрос «а что видит at, когда input и submit пришли одновременно?» зависел бы от порядка подписок — классическая гонка. С задержанным hold ответ определён математически: at видит значение до этого момента. Прошлое стабильно; настоящее ещё формируется.

at вместо «прочитать в обработчике»

Привычка из обработчико-центричного мира — «в колбэке дочитаю, что нужно, из переменных». FRP-эквивалент — at: событие захватывает значение State в свой момент:

ts
// «при сабмите взять текст и очистить поле»
const submitted = draft.at(submitClicks);

Разница с чтением в колбэке — та же, что выше: у at есть точная семантика одновременности, у «дочитаю в обработчике» — порядок исполнения.

Рендеринг: DOM подключён к сети

State в JSX — это привязка, а не значение:

  • {count} в детях — текстовый узел, который патчится при изменении;
  • class={cls} с State — атрибут, который обновляется сам;
  • bindInput(text) — двусторонняя привязка поля (ячейка сама знает свой set).

Пока меняются только значения, структура DOM неподвижна. Когда должна измениться структура, это объявляется явно:

tsx
import { Show, Each } from "@continuum-js/dom";

<Show when={user} fallback={() => <Login />}>
  {(u) => <Profile user={u} />}
</Show>

<Each each={todos} by={(t) => t.id}>
  {(t) => <TodoRow todo={t} />}
</Each>

Show перестраивает поддерево только при смене истинности условия. Each переиспользует строки по ключу и переставляет их минимальным числом перемещений; строка перерендеривается только если её ключ исчез и появился. Granularity правило простое: значение — привязка, структура — Show/Each/dyn.

Владение: кто построил, тот и уберёт

Всё, что компонент создаёт — подписки, таймеры, состояние (hold/accum), эффекты (perform), — регистрируется в дереве владения. Уходит поддерево (переключился Show, удалилась строка Each, размонтировалось приложение) — каскадно выполняются его onCleanup, а процессы состояния отцепляются от источников:

tsx
import { onCleanup, onMount } from "@continuum-js/dom";
import { stream } from "@continuum-js/frp";

function Clock() {
  const tick = stream<number>();
  const id = setInterval(() => tick.fire(Date.now()), 1000);
  onCleanup(() => clearInterval(id));

  const input = (<input />) as HTMLInputElement;
  onMount(() => input.focus()); // узлы уже в документе

  return (
    <div>
      {tick.hold(Date.now()).map((t) => new Date(t).toLocaleTimeString())}
      {input}
    </div>
  );
}

onMount — зеркальный onCleanup: выполняется один раз, когда узлы скоупа вставлены в DOM (фокус, измерения, сторонние библиотеки). Дочерние скоупы монтируются раньше родительских. И это обычные функции — никаких правил хуков: можно звать в условиях, циклах, хелперах. Единственное правило — у состояния должен быть владелец: внутри компонента он есть автоматически, на уровне модуля объявите его явно — root(() => …).

Асинхронность: IO как данные

Сеть чистая; мир входит в неё через явные люки. perform превращает событие запросов в событие результатов, где ошибка — значение, а не исключение:

ts
import { perform, type Result } from "@continuum-js/frp";

const results = perform(queries, async (q): Promise<Page> => fetchPage(q));
// Stream<Result<unknown, Page>> — { ok: true, value } | { ok: false, error }

Для типичного «загрузи и покажи» есть resource из @continuum-js/std: State с состояниями idle | loading | ok | error, которые рендерятся обычным dyn — никакого Suspense-механизма, это просто данные.

Куда дальше

  • FRP-MODEL — операционный разбор: фазы транзакции, ранги, flatten, непрерывное время, с отсылками к книге Functional Reactive Programming (Blackheath & Jones).
  • Из React — таблица соответствий и парные примеры.
  • npm create continuum-js@latest — заготовка проекта, чтобы пробовать по ходу чтения.

Незнакомый термин? Вся терминология этой документации объяснена в глоссарии.