Skip to content

State and derived values

In plain words

Creating state and computing other values from it — no hooks, no dependency arrays.

Two ways to create state

state — the default: a cell you read like any value and write with .set from handlers. If you know useState, this is it (minus the re-runs):

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

const theme = state<"light" | "dark">("light");
<button onClick={() => theme.set("dark")}>dark</button>;

state + .on — the transition form, for when one value changes for several distinct reasons:

ts
import { state, stream } from "@continuum-js/frp";

const inc = stream<void>();
const dec = stream<void>();
const reset = stream<void>();

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

Each .on(event, reducer) declares one transition. The reducer is (state, event) => nextuseReducer argument order — and must be pure. The occurrence and the state's update share one moment, with the usual snapshot semantics: anything reading the state at that moment still sees the value from before it. And however many transitions fire at once, a state delivers one coalesced updates occurrence per moment.

The rule of thumb: values you simply overwrite — .set; values with rules for how they change — .on. .set is the right tool for form fields, toggles, the selected tab. The transition form earns its keep the moment a value has many independent sources of change: every transition reads top-to-bottom in one place, each source keeps its own meaning, and the event streams still compose with stream tools like debounce and at. When the story of a value matters — undo, persistence, sync — fold a single action stream instead, and each of those becomes one more fold.

Derived values — only map/combine

Anything computable from existing states must not be stored:

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

The anti-pattern is "compute and write back with a setter":

ts
// BAD: now you must remember to update total on every change of items
const total = state(0);
itemsChanged.listen(() => total.set(computeTotal()));

That is manual synchronization — exactly what FRP removes. The rule: if a value is derivable, it is a map; state is for primary facts only.

Reading the current value

  • In JSX a state is placed as is — {count} is a binding.
  • In an event handler, use at, not sample:
ts
const submitted = draft.at(submitClicks);

draft.sample() exists and honestly returns the current value, but it has no simultaneity semantics: two simultaneous events would see "some" value depending on ordering. at is defined exactly: the value before the current moment. The practical rule: sample for code outside the network (initialization, tests, integrations), at inside it.

Suppressing redundant updates

A source state already refuses no-op writes: .set with an equal value (by the cell's eq, Object.is by default) does nothing. For derived states, dedupe from @continuum-js/std drops consecutive equal values — useful in front of dyn/Show so a subtree isn't rebuilt for nothing:

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

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

State outside components

The model is one sentence: values are formulas; state and effects belong to a scope.

Formulas travel freely. A source state is a plain object: declare it at module level and import it — that is how the router's location() works, one singleton state per page. Pure derivations (map, combine, filter, at, …) are recipes: they attach to their sources when the first listener arrives, sleep when the last one leaves, and sample() answers at any time regardless. So a module-level derivation is fine as is:

ts
// fine: a formula — no owner needed, no lifetime to manage
export const page = location().map((u) => u.pathname);

State and effects are different. hold, accum, .on, perform (and stateful std helpers such as resource and count) each start a process, and a process must have an owner. Inside a component the owner is the component's scope, automatically — local state lives in the body and dies with the subtree (see Ownership and lifecycle). At module level there is no scope, so Continuum throws a teaching error instead of leaking; declare the lifetime yourself with root():

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

// app-level state: lives as long as the page, and says so
export const count = root(() =>
  state(0)
    .on(inc, (n) => n + 1)
    .on(dec, (n) => n - 1),
);

When a scope is disposed, its state freezes: a hold/accum/.on state keeps answering sample() with its final value but never updates again.

(retain() still exists, but it is purely a performance hint — it keeps a hot shared formula attached across listener churn instead of letting it sleep and re-wake. It is never required for correctness.)


Unfamiliar term? Every piece of jargon in these docs is explained in the glossary.

Selection in lists: selector()

Deriving "is this row selected?" per row (selected.map((s) => s === id)) creates a derivation per row and recomputes all of them on every click. selector() inverts it: ONE process watches the state, each key gets a tiny cell, and changing the selection flips exactly two cells — in one moment:

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

const rowClass = selector(selected, "danger", ""); // value form
// in a row: <tr class={rowClass(row.id)}> — zero extra nodes per row

The watching process belongs to the ambient scope (a component, or root() at module level).