Паттерны
На пальцах
Кулинарная книга. Двадцать два рецепта — от «нужно каждому приложению» до «нишевых, но восхитительных». Каждый — маленькая законченная идея, которую можно унести к себе в код. Пробегитесь по заголовкам и берите нужное.
Рецепты предполагают знание основ: состояние, асинхронность, Streams и States. State — реактивное значение; в FRP-литературе Behavior. Импорты показаны один раз на сниппет; всё берётся из @continuum-js/frp, @continuum-js/dom или @continuum-js/std.
Состояние
1. Actions — Redux без Redux
Когда: одно состояние, много видов изменений (добавить / удалить / переключить…).
Соберите все изменения в один Stream<Action> и сверните его одним чистым редьюсером. accum — это и есть стор; библиотека не нужна.
type Action =
| { type: "add"; text: string }
| { type: "remove"; id: string }
| { type: "toggle"; id: string };
const actions = stream<Action>();
const todos = actions.accum<Todo[]>([], (a, acc) => {
switch (a.type) {
case "add":
return [...acc, createTodo(a.text)];
case "remove":
return acc.filter((t) => t.id !== a.id);
case "toggle":
return acc.map((t) => (t.id === a.id ? { ...t, done: !t.done } : t));
}
});
<button onClick={() => actions.fire({ type: "remove", id: t.id })}>×</button>;Вся логика списка читается сверху вниз в одном месте, а каждое изменение — значение, которое можно логировать, воспроизводить и тестировать.
2. Undo / redo
Когда: у вас уже паттерн actions и хочется историю бесплатно.
Сверните тот же самый поток действий в историю вместо простого значения:
type Hist = { past: Todo[][]; present: Todo[] };
const history = actions.accum<Hist>({ past: [], present: [] }, (a, h) => {
if (a.type === "undo") {
const prev = h.past.at(-1);
return prev ? { past: h.past.slice(0, -1), present: prev } : h;
}
return { past: [...h.past, h.present], present: reduce(a, h.present) };
});
const todos = history.map((h) => h.present);
const canUndo = history.map((h) => h.past.length > 0);Один поток действий — несколько свёрток над ним: это та точка, где FRP начинает окупаться.
3. Не храни то, что можно вывести
Когда: рука тянется сделать два set из одного обработчика.
Два хранимых значения, обязанных совпадать, однажды разойдутся. Храните исходный факт, остальное вычисляйте:
// ❌ два источника истины, синхронизируемых вручную
const items = state<Item[]>([]);
const total = state(0); // забыли обновить один раз — баг
// ✅ один источник, остальное — арифметика
const items = state<Item[]>([]);
const total = items.map((xs) => xs.reduce((s, i) => s + i.price, 0));
const isEmpty = items.map((xs) => xs.length === 0);Производные бесплатны: ни массивов зависимостей, ни ключей мемоизации, ни устаревания.
4. Общий стор в модуле
Когда: одно состояние нужно нескольким компонентам по всему приложению.
Ячейки и чистые производные — обычные значения: экспортируйте свободно. Формула (map/combine) живёт по требованию — подключается по первому слушателю и спит без слушателей. А вот стейтовая свёртка (hold, accum, .on) — процесс, и на уровне модуля её время жизни объявляется явно через root() (см. частые ошибки, №7):
// store.ts
import { state, stream, root } from "@continuum-js/frp";
export const cart = state<Item[]>([]);
export const cartTotal = cart.map(
(xs) => xs.reduce((s, i) => s + i.price, 0), // формула: владелец не нужен
);
export const checkouts = stream<Order>();
export const orderCount = root(
() => checkouts.accum(0, (_o, n) => n + 1), // процесс: живёт до dispose корня
);События как алгебра
5. Прочитать состояние в момент события — at
Когда: обработчику нужно «значение на момент этого клика».
// отправить текущий черновик
const submitted = draft.at(submits);Форма с функцией — draft.at(submits, (text, e) => …) — получает сначала значение, затем само вхождение. Помните про задержку hold: внутри момента читается значение до момента — именно это делает приём композируемым.
6. Поставить поток на паузу — when
Когда: игнорировать ввод, пока что-то в полёте.
const saving = state(false);
const effectiveClicks = saveClicks.when(saving.map((s) => !s));Пока условие ложно, у потока просто нет происшествий — коду ниже не нужны рассыпанные if (saving) return.
7. Только изменения — distinct / dedupe
Когда: шумный источник повторяет одно и то же значение.
import { distinct } from "@continuum-js/frp";
import { dedupe } from "@continuum-js/std";
const realMoves = distinct(moves); // Stream: отбросить подряд идущие равные
const stableTheme = dedupe(theme); // Behavior: подавить пустые обновленияdedupe — способ не дать области dyn/<Dynamic> пересобираться на записи того же значения.
8. Прошлое + текущее — pairwise и previous
Когда: важно направление изменения (скролл вверх/вниз, стрелки тренда).
import { combine } from "@continuum-js/frp";
import { pairwise, previous } from "@continuum-js/std";
const direction = pairwise(scrollY).map(([prev, cur]) =>
cur > prev ? "down" : "up",
);
const lastPrice = previous(price, 0); // State, отстающий на шаг
const trend = combine(price, lastPrice, (now, before) =>
Math.sign(now - before),
);9. Разделить поток — partition
Когда: один источник, две судьбы.
import { partition } from "@continuum-js/std";
const [oks, errs] = partition(responses, (r) => r.ok);10. Парси, а не валидируй — filterMap
Когда: фильтрация и преобразование — на самом деле одна операция.
import { filterMap } from "@continuum-js/std";
// Stream<string> → Stream<number>, невалидный ввод не попадает в сеть вовсе
const amounts = filterMap(inputs, (s) => {
const n = Number(s);
return Number.isFinite(n) ? n : null;
});Ниже этой строки плохих значений не существует — это гарантирует тип.
11. Успокоить пожарный шланг — debounce / throttle
Когда: клавиши, скролл, ресайз.
import { debounce, throttle } from "@continuum-js/std";
const settledQuery = debounce(queryInput, 300); // когда юзер перестал печатать
const scrollSample = throttle(scrolls, 100); // не чаще 10 раз/сек12. Состояние из многих источников — переходы .on
Когда: actions кажется тяжеловесным, а источники по-настоящему независимы.
Идиоматичная форма — объявить переходы прямо на ячейке. Редьюсер получает (состояние, событие); вхождение и обновление — один момент, а несколько одновременных .on-источников сворачиваются последовательно:
const counter = state(0)
.on(plusClicks, (n) => n + 1)
.on(minusClicks, (n) => n - 1)
.on(resetClicks, () => 0);Ни типов действий, ни switch — каждый источник несёт свою семантику с собой. (.on регистрирует процесс в текущем скоупе: в компоненте — автоматически, на уровне модуля оберните в root(...).)
Тот же приём в потоковой форме — слияние событий-функций, если дальше нужна именно алгебра потоков:
const increments = plusClicks.mapTo((n: number) => n + 1);
const decrements = minusClicks.mapTo((n: number) => n - 1);
const resets = resetClicks.mapTo((_: number) => 0);
const counter = increments
.or(decrements)
.or(resets)
.accum(0, (f, n) => f(n));Если два источника могут выстрелить в один момент и оба должны примениться, вместо or возьмите Stream.merge(l, r, (f, g) => (n) => g(f(n))).
Асинхронность
13. IO на границе — perform + Result
Когда: любой эффект. Это тот самый граничный паттерн, на котором стоит вся асинхронность.
import { perform } from "@continuum-js/frp";
const responses = perform(saveRequests, (todo) => api.save(todo));
// Stream<Result<unknown, Saved>> — ошибки приходят данными, а не бросками
const [saved, failed] = partition(responses, (r) => r.ok);14. Поиск без гонок — resource
Когда: ответы могут приходить не по порядку (живой поиск, фильтры).
import { resource } from "@continuum-js/std";
const results = resource(debounce(queries, 300), (q) =>
fetch(`/api/search?q=${encodeURIComponent(q)}`).then((r) => r.json()),
);
// State<Async<T>>: idle → loading → ok | error, «последний запрос
// побеждает» уже встроено
<Dynamic value={results}>
{(r) =>
r.status === "loading" ? <Spinner />
: r.status === "error" ? <Oops error={r.error} />
: r.status === "ok" ? <List items={r.value} />
: <Hint />}
</Dynamic>Классический баг «ответы пришли не по порядку» решён внутри resource нумерацией запросов — обратно его не внести.
15. Оптимистичные обновления
Когда: UI должен отреагировать мгновенно, а с сервером — свериться позже.
Действие применяется локально сразу; если сервер отказал, компенсирующее действие возвращается через тот же редьюсер:
type Action = UserAction | { type: "rollback"; id: string };
const userActions = stream<UserAction>();
const saves = perform(
userActions.filter((a) => a.type === "add"),
(a) => api.save(a),
);
const rollbacks: Stream<Action> = filterMap(saves, (r) =>
r.ok ? null : { type: "rollback", id: r.error.id },
);
const todos = (userActions as Stream<Action>)
.or(rollbacks)
.accum<Todo[]>([], reduce);Цикла здесь нет: perform возвращается в сеть новым моментом.
16. Поллинг с выключателем
Когда: дашборды, счётчики уведомлений.
import { interval } from "@continuum-js/std";
const live = state(true);
const ticks = interval(30_000).when(live);
const stats = resource(ticks, () => fetch("/api/stats").then((r) => r.json()));Переключите live.set(false), когда вкладка скрыта (onMount + слушатель visibilitychange), — и поллинг вместе со всеми запросами ниже по течению остановится как одно целое.
UI
17. Мастер–детали — combine
Когда: выбранный id плюс список — и представления выбранного элемента.
const selectedId = state<string | null>(null);
const selected = combine(
selectedId,
todos,
(id, xs) => xs.find((t) => t.id === id) ?? null,
);
<Show when={selected.map((s) => s !== null)} fallback={() => <PickSomething />}>
{() => <Detail item={selected} />}
</Show>selected не может разойтись со списком: удалите выбранный элемент — и панель деталей закроется по построению.
18. Источник как значение — flatten
Когда: какой источник данных использовать — само по себе состояние. Флагманский нишевый трюк классического FRP.
const source: State<State<Todo[]>> = mode.map((m) =>
m === "local" ? localTodos : serverTodos,
);
const todos = flatten(source);
// код ниже не знает и не хочет знать, что источник подменяемыйВсё построенное над todos — счётчики, фильтры, <Each> — переживает подмену нетронутым. Тот же flatten над State<Stream<A>> переключает потоки событий (например, «какой WebSocket я сейчас слушаю»).
19. Модалка — <Show> + <Portal>
const open = state(false);
<Show when={open}>
{() => (
<Portal mount={document.body}>
<div class="backdrop" onClick={() => open.set(false)}>
<dialog open>…</dialog>
</div>
</Portal>
)}
</Show>;Уборку делает владение: закрытие модалки уничтожает поддерево портала и все подписки внутри него.
20. Императивный островок — ref + onMount + onCleanup
Когда: оборачиваете нереактивную библиотеку (график, карту, редактор).
function Chart(props: { data: State<number[]> }) {
let el!: HTMLDivElement;
onMount(() => {
const chart = new ThirdPartyChart(el); // DOM уже в документе
const un = props.data.listen((d) => chart.setData(d)); // реактивное → императивное
onCleanup(() => {
un();
chart.destroy();
});
});
return <div ref={(e) => (el = e)} />;
}Паттерн всегда из трёх строк: создать в onMount, связать через listen, разобрать в onCleanup. Ничего не течёт — дерево владения само вызовет вашу уборку, когда компонент уйдёт.
21. Персист — persist + loadPersisted
Когда: состояние должно переживать перезагрузку страницы (туду, черновики, настройки UI).
Персист — это сток на границе: прочитать один раз при создании, зеркалить каждое изменение. Обе половинки есть в std — с защитой от битого JSON, отсутствующего стораджа (SSR) и ошибок квоты:
import { persist, loadPersisted } from "@continuum-js/std";
const todos = actions.accum<Todo[]>(loadPersisted("todos", []), reduce);
onCleanup(persist("todos", todos));Для «болтливого» состояния поставьте debounce перед зеркалом; а раз у вас уже паттерн actions, синхронизация вкладок — просто ещё один диспетчер: браузер кидает storage в другие вкладки при каждой записи:
onMount(() => {
const onStorage = (e: StorageEvent) => {
if (e.key === "todos" && e.newValue)
dispatch({ type: "replace", todos: JSON.parse(e.newValue) });
};
window.addEventListener("storage", onStorage);
onCleanup(() => window.removeEventListener("storage", onStorage));
});Добавили туду в одной вкладке — обновились все, а редьюсер не изменился ни на букву.
22. Шарируемая полиморфная кнопка — ComponentProps
Когда: один компонент дизайн-системы, который рендерится как <button> или как ссылка — и принимает ровно те пропсы, что положены каждому варианту.
ComponentProps<"button"> даёт полный типизированный набор атрибутов тега (тот самый React.ComponentProps, к которому вы привыкли); размеченное объединение по as выбирает, какой набор действует:
import type { ComponentProps, Reactive } from "@continuum-js/dom";
type ButtonOwnProps = {
variant?: "primary" | "secondary";
loading?: Reactive<boolean>;
};
type ButtonProps =
// `href?: never` закрывает дыру TS в юнионах: с необязательным
// дискриминантом ссылочные пропсы иначе пролезали бы в кнопочную ветку.
| (ComponentProps<"button"> &
ButtonOwnProps & { as?: "button"; href?: never })
| (ComponentProps<"a"> & ButtonOwnProps & { as: "a" });
export function Button(props: ButtonProps) {
const { as, variant = "primary", loading, ...rest } = props;
const cls = `btn btn-${variant}`;
return as === "a" ? (
<a class={cls} {...(rest as ComponentProps<"a">)} />
) : (
<button
class={cls}
disabled={loading}
{...(rest as ComponentProps<"button">)}
/>
);
}<Button type="submit" loading={saving} onClick={(e) => save(e)}>Сохранить</Button>
<Button as="a" href="/docs" target="_blank">Документация</Button>
<Button href="/docs">…</Button> // ✗ ошибка компиляции: href требует as="a"Что стоит унести с собой:
- children проходят через спред — рантайм доставляет их в
props.children, так что{...rest}уносит их в тег; loading— этоReactive<boolean>: вызывающий передаёт хотьtrue, хоть живойState<boolean>, иdisabledследит за ним без проводки;- обработчики результата полностью типизированы, включая
e.currentTarget(HTMLButtonElementилиHTMLAnchorElementпо ветке).
Не нашли нужный рецепт? Откройте issue — лучшие паттерны в этом списке начинались с чьего-то «а как сделать…».