Patterns
In plain words
A cookbook. Twenty-two recipes from "every app needs this" to "niche but delightful" — each one is a small, complete idea you can lift into your code. Skim the headings, steal what you need.
The recipes assume the basics: state, async, events vs states. Imports are shown once per snippet; everything comes from @continuum-js/frp, @continuum-js/dom or @continuum-js/std.
State
1. Actions — Redux without Redux
When: one piece of state, many kinds of changes (add / remove / toggle…).
Give each kind of change its own event and declare the transitions right on the state. Each reducer is (state, event) => next — useReducer, minus the dispatch ceremony:
import { state, stream } from "@continuum-js/frp";
const adds = stream<string>();
const removes = stream<string>();
const toggles = stream<string>();
const todos = state<Todo[]>([])
.on(adds, (acc, text) => [...acc, createTodo(text)])
.on(removes, (acc, id) => acc.filter((t) => t.id !== id))
.on(toggles, (acc, id) =>
acc.map((t) => (t.id === id ? { ...t, done: !t.done } : t)),
);
<button onClick={() => removes.fire(t.id)}>×</button>;All list logic reads top-to-bottom in one place, and every change is a value you can log, replay or test. When the changes themselves must be data — one serializable log to replay or persist — collect them into a single Stream<Action> and fold it with accum instead; the next recipe does exactly that.
2. Undo / redo
When: you want history for free.
This is where a single action stream beats separate events: fold the same stream into a history instead of a plain value:
import { stream } from "@continuum-js/frp";
type Hist = { past: Todo[][]; present: Todo[] };
const actions = stream<Action>();
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);One stream of actions, several folds over it — this is the point where FRP starts paying rent.
3. Never store what you can derive
When: you're tempted to set two things from one handler.
Two stored values that must agree will eventually disagree. Store the source fact; compute the rest:
// ❌ two sources of truth that must be kept in sync by hand
const items = state<Item[]>([]);
const total = state(0); // forget to update this once — bug
// ✅ one source, the rest is arithmetic
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);Deriving is free: no dependency arrays, no memo keys, no staleness.
4. A shared store in a module
When: several components across the app need the same state.
Values are formulas; state belongs to a scope. A source state and any pure derivation are safe at module level as they are. A stateful fold (.on, accum, hold) starts a process, and at module level you must say who owns it — wrap it in root() (see common mistakes #7):
// store.ts
import { state, stream, root } from "@continuum-js/frp";
export const adds = stream<Item>();
// state with transitions — root() declares its lifetime (the page's)
export const cart = root(() =>
state<Item[]>([]).on(adds, (xs, item) => [...xs, item]),
);
// a pure derivation is a formula: no owner, no ceremony
export const cartTotal = cart.map((xs) => xs.reduce((s, i) => s + i.price, 0));A plain state() that you only .set from handlers needs no root() — only stateful folds and effects do.
Streams as algebra
5. Read state at the moment of an event — at
When: a handler needs "the value as of this click".
// submit the current draft, then clear it
const submitted = draft.at(submits);Pass a combiner when the event's payload matters too: draft.at(submits, (text, click) => …) receives (value, event). Remember the hold delay: within one moment you read the value from before the moment — that's what makes this composable.
6. Pause a stream — when
When: ignore input while something is in flight.
const saving = state(false);
const effectiveClicks = saveClicks.when(saving.map((s) => !s));The stream simply has no occurrences while the condition is false — downstream code doesn't need if (saving) return sprinkled everywhere.
7. Only changes — distinct / dedupe
When: a noisy source repeats the same value.
import { distinct } from "@continuum-js/frp";
import { dedupe } from "@continuum-js/std";
const realMoves = distinct(moves); // Stream: drop consecutive equals
const stableTheme = dedupe(theme); // State: suppress no-op updatesdedupe is how you stop a dyn/<Dynamic> region from rebuilding on same-value writes.
8. Previous + current — pairwise and previous
When: direction of change matters (scroll up vs down, trend arrows).
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 lagging one step behind
const trend = combine(price, lastPrice, (now, before) =>
Math.sign(now - before),
);9. Split a stream — partition
When: one source, two fates.
import { partition } from "@continuum-js/std";
const [oks, errs] = partition(responses, (r) => r.ok);10. Parse, don't validate — filterMap
When: filter and transform are really one operation.
import { filterMap } from "@continuum-js/std";
// Stream<string> → Stream<number>, invalid input never enters the network
const amounts = filterMap(inputs, (s) => {
const n = Number(s);
return Number.isFinite(n) ? n : null;
});Downstream of this line, bad values cannot exist — the type says so.
11. Calm a firehose — debounce / throttle
When: keystrokes, scroll, resize.
import { debounce, throttle } from "@continuum-js/std";
const settledQuery = debounce(queryInput, 300); // after the user stops typing
const scrollSample = throttle(scrolls, 100); // at most 10/sec while active12. Multi-source state — .on
When: independent sources each change one value in their own way.
Declare one transition per source, right on the state:
const counter = state(0)
.on(plusClicks, (n) => n + 1)
.on(minusClicks, (n) => n - 1)
.on(resetClicks, () => 0);No action types, no switch — each source carries its own semantics. If two sources fire in the same moment, the transitions fold sequentially in declaration order, and the state still delivers a single coalesced update for that moment.
Async
13. IO at the boundary — perform + Result
When: any effect. This is the boundary pattern; everything async builds on it.
import { perform } from "@continuum-js/frp";
const responses = perform(saveRequests, (todo) => api.save(todo));
// Stream<Result<unknown, Saved>> — errors are data, not throws
const [saved, failed] = partition(responses, (r) => r.ok);14. Race-free search — resource
When: requests can arrive out of order (typeahead, filters).
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, last-request-wins built in
<Dynamic value={results}>
{(r) =>
r.status === "loading" ? <Spinner />
: r.status === "error" ? <Oops error={r.error} />
: r.status === "ok" ? <List items={r.value} />
: <Hint />}
</Dynamic>The out-of-order-response bug is solved inside resource by request numbering — you can't reintroduce it.
15. Optimistic updates
When: the UI should react instantly and reconcile with the server later.
Apply the action locally at once; if the server refuses, a compensating action flows back in through the same reducer:
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);There's no cycle: perform re-enters the network in a new moment.
16. Polling with a pause switch
When: dashboards, notification counters.
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()));Flip live.set(false) when the tab hides (an onMount + visibilitychange listener) and the polling — and every request downstream — stops as one.
UI
17. Master–detail — combine
When: a selected id plus a list, and views of the chosen item.
import { combine } from "@continuum-js/frp";
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 can never disagree with the list: delete the selected item and the detail pane closes by construction.
18. A source as a value — flatten
When: which data source to use is itself state. The flagship niche trick of classic FRP.
import { flatten } from "@continuum-js/frp";
const source: State<State<Todo[]>> = mode.map((m) =>
m === "local" ? localTodos : serverTodos,
);
const todos = flatten(source);
// downstream code doesn't know or care that the source can be swappedEverything built on todos — counts, filters, the <Each> — survives the swap untouched. The same call flattens a state of event streams (State<Stream<A>> → Stream<A>) — e.g. "which WebSocket am I listening to".
19. Modal — <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>;Ownership does the cleanup: closing the modal disposes the portal's subtree and every subscription inside it.
20. An imperative island — ref + onMount + onCleanup
When: wrapping a non-reactive library (a chart, a map, an editor).
function Chart(props: { data: State<number[]> }) {
let el!: HTMLDivElement;
onMount(() => {
const chart = new ThirdPartyChart(el); // DOM is in the document here
const un = props.data.listen((d) => chart.setData(d)); // reactive → imperative
onCleanup(() => {
un();
chart.destroy();
});
});
return <div ref={(e) => (el = e)} />;
}The pattern is always the same three lines: create in onMount, bridge with listen, tear down in onCleanup. Nothing leaks — the ownership tree calls your cleanup when the component goes.
21. Persistence — persist + loadPersisted
When: state should survive a page reload (todos, drafts, UI preferences).
Persistence is a sink at the boundary: read once at creation, mirror every change. Both halves ship in std — safe against corrupted JSON, missing storage (SSR) and quota errors:
import { persist, loadPersisted } from "@continuum-js/std";
const todos = actions.accum<Todo[]>(loadPersisted("todos", []), reduce);
onCleanup(persist("todos", todos));For chatty state, debounce the mirror; and since you already have an action stream, cross-tab sync is just one more source that fires into it — the browser fires storage in other tabs on every write:
onMount(() => {
const onStorage = (e: StorageEvent) => {
if (e.key === "todos" && e.newValue)
actions.fire({ type: "replace", todos: JSON.parse(e.newValue) });
};
window.addEventListener("storage", onStorage);
onCleanup(() => window.removeEventListener("storage", onStorage));
});Add a todo in one tab — every tab updates, and the reducer didn't change by a letter.
22. A shareable, polymorphic button — ComponentProps
When: one design-system component that renders as a <button> or as a link, accepting exactly the right props for each.
ComponentProps<"button"> gives you a tag's full typed attribute set (the React.ComponentProps you're used to); a discriminated union on as picks which set applies:
import type { ComponentProps, Reactive } from "@continuum-js/dom";
type ButtonOwnProps = {
variant?: "primary" | "secondary";
loading?: Reactive<boolean>;
};
type ButtonProps =
// `href?: never` closes a TS union hole: with the optional discriminant,
// link props would otherwise be accepted on the button branch.
| (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)}>Save</Button>
<Button as="a" href="/docs" target="_blank">Docs</Button>
<Button href="/docs">…</Button> // ✗ compile error: href requires as="a"Notes worth stealing:
- children forward through the spread — the runtime delivers them as
props.children, so{...rest}carries them into the tag; loadingisReactive<boolean>: callers pass a plaintrueor a liveState<boolean>anddisabledtracks it — no wiring;- handlers on the result are fully typed, including
e.currentTarget(anHTMLButtonElementorHTMLAnchorElementper branch).
Want a recipe that isn't here? Open an issue — the best patterns in this list started as someone's "how do I…".