@demlik/tea
A TEA / Elm-Architecture TypeScript library for durable, replayable state machines — one pure reducer, every host adapter.
Install
pnpm add @demlik/tea
The one runtime dependency is @standard-schema/spec, a types-only package
installed for you. Cmd.define takes any Standard Schema, so bring the schema
library you already use (zod as it is, or Effect Schema through the
Cmd.make that @demlik/tea/effect exports). The host and
testing adapters ride on optional peers you add only for the surface you use:
react / react-dom (the ./react hooks), ws (Node WebSocket subs),
fast-check (the ./pbt property-testing helpers), vitest (the testing
utilities), effect (the ./effect engine), and @opentelemetry/api (the
./otel spans).
Quickstart
A machine is plain data: an init and an update map keyed by Msg type.
run drives it, and when it has effects you hand run the interpret map
that performs them; dispatch folds a Msg; getState reads the current
Model. You name the Model and the Msg union
once, under types; everything else is inferred from the machine itself. Each of
these words has an entry in the glossary.
import { defineMachine } from "@demlik/tea";
import { run } from "@demlik/tea/promise";
type State = { readonly count: number };
type Msg = { readonly type: "increment" } | { readonly type: "reset" };
const counter = defineMachine({
types: { model: {} as State, msg: {} as Msg },
init: (loaded) => (loaded !== null ? [loaded, []] : [{ count: 0 }, []]),
update: {
increment: (s) => [{ count: s.count + 1 }, []],
reset: () => [{ count: 0 }, []],
},
});
const runtime = await run(counter, {}).ready;
await runtime.dispatch({ type: "increment" });
runtime.getState(); // { count: 1 }
Two engines, one machine
The core is @demlik/tea, and each engine has its own entry point. A machine
file imports only the core, so it runs unchanged on either engine.
@demlik/tea— the core:defineMachine,Cmd,replayand the types. It imports no engine.@demlik/tea/promise— the Promise engine. Handlers return Promises.@demlik/tea/effect— the Effect engine (stable, Effect v4). Handlers return Effects, services come from your Layers, the handle's verbs return Effects with typed errors, and closing the scope interrupts whatever is in flight.
Run a machine on the Promise engine
and on the Effect engine
build on the same machine file.
Run many machines under one parent
starts child machines on the Effect engine with spawn, tell and stop.
Coming from 0.15?
The migration guide
shows every removed or reshaped API, before and after.
React
useMachine from @demlik/tea/react returns [state, send]; send(msg) returns
undefined immediately, and the run's onError hears step failures once. A Msg
held at unmount or sent after stop reaches the sink as DispatchDiscardedError
under phase: "discard". useRuntime(run, { onError }) renders a booted run from
either engine, or a hand-built handle with a Promise-returning dispatch, with
the same [state, send] contract. Its optional sink hears discards and hand-built
dispatch failures; a run keeps its own step-error sink.
useMachine takes either engine's run. For the Effect engine pass
runIn(runtime) from @demlik/tea/effect, with your ManagedRuntime; its
interpret cells return an Effect and its subscribe runners a Stream, and the
lifecycle (one run per mount under Strict Mode, Subs paused on an <Activity>
hide, the run stopped on unmount) is the same.
useRuntime(run, (model) => model.views[id]) reads one part and re-renders on runtime updates only when that selected value changes (Object.is).
useBackgroundRuntime from @demlik/tea/extension keeps { state, dispatch },
with a void-returning dispatch. Its optional onError hears transport failures
under phase: "follow-up"; background step failures go to the background run.
The context Provider and useDispatch follow the same contract.
Read the state for the outcome. Code that previously awaited a hook's dispatch
should remove the await; host code that needs to wait should own the run and use
its engine's dispatch, idle, or done.
Drive a machine from React shows click handlers, lifecycle behavior, and error sinks.
Durability
With fileStore or doStore, state is saved before commands run. Restarting loads it.
agent.run resumes pending work automatically; custom machines handle a boot
message. An effect can run again if its result was not saved before the crash.
Make a machine durable and understand restore versus replay.
Documentation
The four Diátaxis quadrants live in docs/:
- Tutorials — learning-oriented lessons that take you through the library by building a real machine.
- How-to guides — goal-oriented directions for getting a specific job done.
- Reference — information-oriented API description, generated per public module.
- Explanation — understanding-oriented discussion of how it works and why it is shaped this way.
The glossary defines each word those pages use: Model, Msg, Cmd, Sub, Store and the rest.
Two more surfaces sit behind those, for people working on the library rather than using it:
.patterns/ is how the code is shaped, and
.decisions/ is why it was shaped that way.