@playfast/reform
v1.4.1
Published
The renderer-neutral core of the reform framework — typed, headless state, events, reducers, derived values, async/remote data, and compositions built on Effect.
Maintainers
Readme
@playfast/reform
The renderer-neutral core of the reform framework. State, events, reducers, derived values, async & remote data, and compositions — headless, typed end to end, and provable without a DOM.
reform is an application framework built on Effect. You describe your app as definitions — state, events, reducers, calculations, compositions — and provide their behavior separately as Effect layers. The core renders nothing: a host package (@playfast/reform-react, @playfast/reform-react-native) turns a closed scene into a live tree, and @playfast/reform-proof drives that same scene headlessly in tests. One model, three consumers, no seam between them.
Install
bun add @playfast/reform effect
# npm install @playfast/reform effect · pnpm add @playfast/reform effecteffect is a peer dependency. react is an optional peer (only the React-facing primitives need it).
Contents
- The core idea: definition / implementation split
- The model
- Reference
- Putting it together
- The reform family
- License
The core idea: definition / implementation split
Every primitive comes in two halves. X.make(…) is a reflectable definition — a manifest plus a DI tag, safe to import anywhere and to inspect. X.live(…) provides its behavior as a layer. Nothing self-registers on import, so the dependency graph is explicit, tree-shakeable, and fully testable.
import { Schema as S } from 'effect'
import { State, Event, Reducer } from '@playfast/reform'
class Count extends State.make('count', S.Number) {}
class Bumped extends Event.make('Bumped', S.Struct({ by: S.Number })) {}
class Bump extends Reducer.make('Bump', { states: [Count], events: [Bumped] }) {}
const BumpLive = Reducer.live(Bump, (n, e) => n + e.by)A State lives in a plain reactive store; reducers only return values, so by construction they are the only writers. Reads return the current snapshot through Effect.
The primitives at a glance
| primitive | definition | implementation |
| ------------------------------------------------ | ------------------------------------------------------------------------ | ---------------------------------- |
| State / StateGroup / StateFamily | State.make(name, schema, opts?) | .live(initial) |
| Event / EventGroup | Event.make(name, schema) | — (pure data) |
| Reducer | Reducer.make(name, { states / family, events }) | .live(fold) |
| Calc / CalcFamily | Calc.make(name, { inputs, output }) | .live(fn) |
| AsyncCalc | AsyncCalc.make(name, { inputs, output, error?, alwaysOn? }) | .live({ query, … }) |
| RemoteState | RemoteState.make(name, { inputs, output, error?, alwaysOn?, intents }) | .live({ query, send, apply, … }) |
| Boundary | Boundary.make(name, { over }) | .live({ once? }) |
| Procedure | Procedure.make(name, { events, channel }) | .live(fn*) |
| Channel | Channel.make(name, { policy }) | .live() |
| Composition | Composition.make(name, manifest) | .live(fn*) |
| ui / slot | ui(name)<C>() / slot(name, identity)<Comp>() | Ui.make / provide |
| Feature | Feature.make(name, config) | (eager module / lazy load) |
The model
UI trigger ──► Bus (High) ─┐
├─► reduce loop ──► reducers (the only writers) ──► stores
procedure ──► Bus (Normal)┘ │
▲ ▼
└────────────── reads state, dispatches ◄──── calc (derived, memoized)- The bus is one
PubSub. A central drain loop batches events per microtask and runs matching reducers —High-priority (UI) folds beforeNormal-priority (procedure) folds within a frame. Procedures consume the bus on their own forked fibers, scheduled by their channel's concurrency policy. - Derived reads take any
Source(a state member, aCalc, anAsyncCalc, aRemoteState) and accept an optionalinvalidateBykey projection that bounds recompute to a value-equal key change. - Notifications coalesce into a single microtask flush, which the host's
useSyncExternalStorebridge binds to.
State · StateGroup · StateFamily
Reactive stores held outside Effect. Reducers are the sole writers; everything else reads.
State
State.make(name, schema, options?) // → StateClass
State.live(State, initial) // → Layer<Store<value>>options is { title?, description? } (reflectable metadata). A State class is itself yieldable — yield* Count reads its current value.
StateGroup
A bundle of related states provided together. StateGroup.select(Group, 'name') returns a StateToken (a Source) used both as a calc input and as a yieldable read.
import { State, StateGroup } from '@playfast/reform'
class Count extends State.make('count', S.Number) {}
class Step extends State.make('step', S.Number) {}
class Counter extends StateGroup.make(Count, Step) {}
const CounterLive = StateGroup.live(Counter, { count: 0, step: 1 }) // all members required
// read inside any Effect / composition:
const count = yield * StateGroup.select(Counter, 'count')StateFamily
A keyed collection — one store per key over a shared schema.
StateFamily.make(name, keySchema, valueSchema, options?) // → StateFamilyClass
StateFamily.live(Family, initial | (key) => value, { evictWhenUnused? }?)
class Items extends StateFamily.make('items', S.String, ItemSchema) {}
const ItemsLive = StateFamily.live(Items, (id) => blankItem(id), { evictWhenUnused: true })
const item = yield* StateFamily.read(Items, id)evictWhenUnused: true ref-counts each key and drops it on the next microtask once its last subscriber leaves (re-access re-seeds from initial). A family reducer fold may return StateFamily.Tombstone to evict a key.
StateToken/Source/AnySource—Source<N, A>is the structural shape ({ name, store }) thatStateToken,Calc,AsyncCalc, andRemoteStateall satisfy, so any of them can feed a calc'sinputs.AnySource = Source<string, any>.
Event · EventGroup
Events are pure tagged data — definitions only, no .live.
Event.make(name, schema) // → EventClass; EventOf<N, P> = { _tag: N } & P
EventGroup.make(...events) // → bundle, used in Reducer/Composition manifests
class LoadedTodos extends Event.make('LoadedTodos', S.Struct({ todos: S.Array(Todo) })) {}Dispatching. Two idioms, two priorities:
// From a UI view — High priority, synchronous:
const toggle = yield * Event.trigger(ToggledTodo) // toggle: (payload) => void
// ...later: <input onChange={() => toggle({ id })} />
// From a procedure — Normal priority:
yield * Event.dispatch(TodoUpserted, { todo })Event.trigger returns a plain callback (Trigger<P>) you hand to the view; Event.dispatch returns an Effect you yield inside logic.
Reducer
The only writers of state. A fold is a pure, synchronous (value, event) => value (async values throw at startup).
Reducer.make(name, { states: [State], events: [...] }) // state reducer
Reducer.make(name, { family: Family, keyOf, events: [...] }) // family reducer
Reducer.live(Reducer, fold)State reducers receive and return the whole state value; family reducers receive and return one entry (or StateFamily.Tombstone). The idiomatic fold matches on the event tag with Effect's Match:
import { Match } from 'effect'
const FeedReducerLive = Reducer.live(FeedReducer, (feed, event) =>
Match.value(event).pipe(
Match.tags({
StartedLoading: (): Feed => ({ _tag: 'Loading' }),
LoadedTodos: ({ todos }): Feed => ({ _tag: 'Ok', todos }),
FailedTodos: ({ message }): Feed => ({ _tag: 'Error', message }),
TodoRemoved: ({ id }) =>
onOk(
feed,
Array.filter((t) => t.id !== id),
),
}),
Match.exhaustive,
),
)An event outside the reducer's declared events never reaches the fold; the drain loop owns every store.set, so logic can never write state directly.
Calc · CalcFamily
Synchronous derived values — memoized projections over one or more sources.
Calc.make(name, { inputs, output }) // inputs: ReadonlyArray<Source>
Calc.live(Calc, (inputs) => output, { invalidateBy?, reuse? }?)
class IsPositive extends Calc.make('IsPositive', {
inputs: [StateGroup.select(Counter, 'count')],
output: S.Boolean,
}) {}
const IsPositiveLive = Calc.live(IsPositive, ({ count }) => count > 0)
const positive = yield* IsPositive // read the memoized valueinputs are keyed in the compute argument by each source's name. A calc recomputes only when its invalidation key changes (default: all input values; override with invalidateBy: (inputs) => [...]). reuse: true does structural sharing on the output, keeping unchanged subtree identities stable across recomputes.
CalcFamily parameterizes a calc by key, one memoized store per key over shared inputs — each member notifies only its own subscribers:
class GroupView extends CalcFamily.make('GroupView', {
key: GroupId,
inputs: [Board],
output: GroupSchema,
}) {}
const GroupViewLive = CalcFamily.live(
GroupView,
(id) =>
({ Board }) =>
project(Board, id),
{ evictWhenUnused: true },
)
const view = yield * CalcFamily.read(GroupView, groupId)AsyncCalc & AsyncData
Server reads with a stale-while-revalidate lifecycle. The query re-runs reactively from its inputs.
AsyncCalc.make(name, { inputs, output, error?, alwaysOn? })
AsyncCalc.live(AsyncCalc, {
query, // (inputs) => Effect<A, E, R>
invalidateBy?, // (inputs) => ReadonlyArray<unknown> — refetch only when this key changes
invalidateOn?, // ReadonlyArray<Event> — also refetch on these events
coalesce?, // 'switch' (default, latest-wins) | 'trailing' (one trailing refetch after a burst)
reuse?, // structural-share Success values across refetches
keepPreviousData?, // show the previous input key's result while the new key loads
disabled?, // (inputs) => boolean — gate the query off (Idle); gatable calcs only
})A re-fetch is not a key change. Re-validating the same inputs keeps the last
Success/Errorunderrefetching: true. When the inputs move, the retained result answers a question no one is asking any more, so it is not shown: the value readsLoading(orIdlewhen gated off) until the new key settles. SetkeepPreviousData: trueto opt back into showing it — safe when the payload is only displayed, unsafe when consumers read fields off it (a permission flag on the previous key's row is a real hazard, not a flicker).erroromitted ⇒ the query is infallible (noErrorarm).alwaysOn: true⇒ noIdlearm anddisabledis rejected.Reading
yield* MyAsyncCalcyields anAsyncData<A, E, Gated>:
| arm | _tag | fields | when |
| -------------- | ----------- | --------------------- | ----------------------------------------------------------------- |
| AsyncIdle | 'Idle' | — | gated query is disabled |
| AsyncLoading | 'Loading' | — | first fetch, or the inputs moved and the new key is still loading |
| AsyncSuccess | 'Success' | value, refetching | succeeded (refetching: true while re-fetching) |
| AsyncError | 'Error' | error, refetching | failed |
class Doubled extends AsyncCalc.make('Doubled', {
inputs: [StateGroup.select(Counter, 'count')],
output: S.Number,
alwaysOn: true,
}) {}
const DoubledLive = AsyncCalc.live(Doubled, {
query: ({ count }) => Effect.succeed(count * 2),
})
const data = yield * Doubled
Match.value(data).pipe(
Match.tag('Loading', () => spinner),
Match.tag('Success', ({ value }) => render(value)),
Match.exhaustive,
)RemoteState
Server-owned state with optimistic mutations, fused into one primitive. Remote state is derived-only: the only writer is the server, the only write surface is dispatching a declared intent, and the visible value is pending.reduce(apply, serverTruth).
RemoteState.make(name, { inputs, output, error?, alwaysOn?, intents }) // intents: Event definitions
RemoteState.live(Remote, {
query, // (inputs) => Effect<A, E, R> — same as AsyncCalc
send, // (intent) => Effect<_, _, R2> — deliver one intent to the server
apply, // (value, intent) => value — pure, total, idempotent overlay fold
channel?, // send lane (default: a generated `merge` channel)
invalidateBy?, invalidateOn?, coalesce?, reuse?, disabled?, // inherited
})Class surface:
yield* Board→ the overlaidAsyncData<A, E, Gated>(server truth with pending intents applied).Board.truth→ the un-overlaid query lifecycle (aSource), for chrome that must show raw server state.Board.pending→ read-onlySourceofReadonlyArray<PendingIntent<I>>({ opId, intent, status: 'sending' | 'confirmed' }).Board.Failed→ a publicEventcarryingFailedIntent({ intent, error }) for toasts / retry UX.
class Board extends RemoteState.make('Board', {
inputs: [Session, StateGroup.select(Router, 'route')],
output: BoardSnapshot,
error: S.String,
intents: [ItemAddIntent, ItemRenameIntent],
}) {}
const BoardLive = RemoteState.live(Board, {
query: ({ route }) => loadBoard(route.boardId),
send: (intent) =>
Match.value(intent).pipe(
Match.tag('ItemAddIntent', ({ groupId, name }) => client.AddItem({ groupId, name })),
Match.tag('ItemRenameIntent', ({ itemId, name }) => client.RenameItem({ id: itemId, name })),
Match.exhaustive,
),
apply: (snapshot, intent) => applyIntent(snapshot, intent), // idempotent
invalidateOn: [BoardChanged],
coalesce: 'trailing',
})Semantics. A dispatched intent appends to the queue and appears in the overlay in the same flush. On send failure the intent settles immediately and Board.Failed fires — there is no rollback machinery, the derivation just converges. The generation rule guarantees an optimistic change never flickers out between the RPC confirming and the refetch landing: an intent acked at query generation g is settled only by a later-generation Success run.
Boundary
Merges the lifecycle arms of several async sources into one first-load signal, so a subtree shows a single fallback instead of per-query spinners.
Boundary.make(name, { over }) // over: ReadonlyArray<AsyncCalc | RemoteState | ...>
Boundary.live(Boundary, { once? }?)
class BootBoundary extends Boundary.make('BootBoundary', { over: [Session, BootstrapQuery, Board] }) {}
const BootBoundaryLive = Boundary.live(BootBoundary, { once: true })
const boot = yield* BootBoundary // BoundaryStateyield* BootBoundary yields a BoundaryState:
BoundaryPending— some source is on its first load.BoundaryReady— every source has settled (Success, evenrefetching, or a deliberately gatedIdle).BoundaryErrored— a source errored before first value; carrieserrors: ReadonlyArray<unknown>.
once: true latches: once Ready, it stays Ready (a boot boundary won't re-splash when a later navigation first-loads a route-gated query). It latches only on converged values, never mid-flush.
Procedure & Channel
Procedures are side-effecting reactions to events, running on forked fibers. A channel is the named concurrency lane they run on.
Channel.make(name, { policy }) // policy: 'merge' | 'latest' | 'debounce' | 'throttle' | 'exclusive'
Channel.live(Channel)
Procedure.make(name, { events, channel })
Procedure.live(Procedure, function* (event) { … })The generator body receives the matched event, can read services from context, and dispatches follow-up events with Event.dispatch:
class ActionsChannel extends Channel.make('Actions', { policy: { _tag: 'merge' } }) {}
class CreateTodo extends Procedure.make('CreateTodo', {
events: [SubmittedNewTodo],
channel: ActionsChannel,
}) {}
const CreateTodoLive = Procedure.live(CreateTodo, function* (event) {
const client = yield* TodosClient
const result = yield* Effect.either(client.AddTodo({ text: event.text }))
yield* Match.value(result).pipe(
Match.tags({
Right: ({ right }) => Event.dispatch(TodoUpserted, { todo: right }),
Left: ({ left }) => Event.dispatch(FailedTodos, { message: String(left) }),
}),
Match.exhaustive,
)
})Policies: merge (unbounded concurrency), latest (new event cancels the in-flight run), exclusive (serialized), debounce/throttle (rate-shaping). Many procedures may share one channel.
Composition · ui · slot · provide
A composition is a unit of logic that reads state and renders a typed UI contract. The contract (ui) and its presentation are authored separately, so logic stays renderer-neutral.
// 1. declare the contract — props the view receives, events it can fire
class CounterUi extends ui('Counter')<{
props: { count: number }
events: { bump: Trigger<{}> }
}>() {}
// 2. declare the composition and what it reads
class Counter extends Composition.make('Counter', { ui: CounterUi, states: [Count] }) {}
// 3. implement the logic — read sources, return the view applied to computed props
const CounterLive = Composition.live(Counter, function* () {
const count = yield* StateGroup.select(Counters, 'count')
const bump = yield* Event.trigger(Bumped)
const view = yield* CounterUi
return view({ count }, { bump })
})
// 4. provide a presentation for the contract (DOM/native/custom)
const CounterView = provide(CounterUi, Ui.make(CounterUi, ({ count }, _slots, { bump }) =>
<button onClick={() => bump({})}>{count}</button>
))Composition.make(name, manifest)— manifest declaresuiplus thestates/calcs/events/slotsit touches (all reflectable).Composition.live(comp, fn*)— the generator reads sources and returns aNode(the view applied to props/events).slot(name, identity)<Comp>()— a hole a parent composition declares (slots: { body: BodySlot }) and fills withprovide(BodySlot, ChildComposition)(or aFeature). Declareidentityas a named module-level unique symbol (const BodySlotIdentity: unique symbol = Symbol('BodySlot'));Symbol.for(...)and inlineSymbol(...)arguments are forbidden. Reform generates a separate runtime key for every slot definition, whilenameremains a display label, so independently authored same-name slots remain distinct. Children render through the host.provide(...)— binds a UI presentation to a contract, or fills a slot with a composition / feature. Returns aLayer.
Feature
The lazy/eager code-split unit. Definitions stay eager and reflectable; the heavy implementation can load on demand as an Effect (never a bare Promise), so code-splitting composes with the rest of the layer graph.
import { Feature, featureModule, lazyImport, mountFeature } from '@playfast/reform'
// eager: ship the module with the definition
class Counter extends Feature.make('counter', {
composition: CounterComp,
module: featureModule([], CounterLive),
boot: [Event.construct(Tick, {})],
}) {}
// lazy: defer the module behind an import
class Reports extends Feature.make('reports', {
loadingStrategy: 'lazy',
composition: ReportsComp,
load: lazyImport(() => import('./reports.module')),
eager: featureModule([], PlaceholderLive),
placeholder: { loading: SpinnerComp, failed: RetryComp },
}) {}A feature's requires tuple accepts shared State/Calc carriers, StateGroups, and
ordinary Effect Context.Tags. A lazy feature shares the app's engine but gets
its own scope — mountFeature(binding, parentContext) loads it, builds its layer
against the immediate parent, dispatches its boot events, and disposes everything
when the scope closes. Fill a slot with one via provide(SomeSlot, Reports);
provide merges its optional eager placeholder module automatically while the
host paints the placeholders and loads the main module.
Default Features retain their binding identity too. Their module is merged by
provide, their composition renders synchronously from the parent runtime, and
their boot events run once for each keyed activation.
Scene
A scene bundles a root composition with the closed wiring that runs it — the single handle the React host, React Native host, and proofs all consume.
scene(composition, { provide: [...layers], boot?: [...events] }) // → Scene
featureScene(eagerFeature, { provide: closedEnvironment }) // → Scene
seedScene(base, seeds) // tooling: override seed values
isScene(value) // reflection guard
const AppScene = scene(AppRoot, {
provide: [Engine, AppStateLive, AppLogicLive, AppViews],
boot: [Event.construct(AppStarted, {})],
})provide is the list of layers that close the app (they must resolve to MountedServices — every composition's render service plus the Bus). Hand the scene to @playfast/reform-react to mount or to @playfast/reform-proof to assert. seedScene overlays seed values onto already-closed layers (used by the dev tool to preview alternative initial state).
featureScene is the typed root helper for a default/eager Feature. Its provide
layer closes the engine plus the Feature's declared requirements; lazy Features
are rejected because an application root must be immediately available.
Engine & runtime surface
Engine is the layer that ties a runtime together: it provides the Bus (one PubSub), the Reducers / Channels / Procedures registries, and forks the single drain loop. Merge it at the root of your app's layers:
import { Engine } from '@playfast/reform'
const AppLayer = Layer.mergeAll(
Engine,
StateGroup.live(AppStates, AppSeeds),
AppReducersLive,
AppProceduresLive,
AppClientsLive,
)Also exported for hosts and headless tests: Bus / publish(priority, event) / Priority ('High' | 'Normal'); the Reducers / Channels / Procedures registries and their ReducerEntry / ProcedureEntry shapes; CaptureSink (the capturing UI sink proofs assert against); the tagged errors (AsyncReducer, AsyncSceneLayer, DuplicateRegistration, FeatureLoadFailed, InvalidProvideTarget, UnknownGroupState); and the notification scheduler — Notifications / notificationsLayer / makeScheduler / defaultScheduler, which hosts provide upstream for per-runtime isolation (concurrent SSR, multiple mounted roots). Everything else coalesces on the process-wide default scheduler.
Putting it together
A minimal counter, end to end:
import { Schema as S, Layer, Match } from 'effect'
import {
State, StateGroup, Event, Reducer, Composition, ui, provide, Ui, Engine, scene,
type Trigger,
} from '@playfast/reform'
// state + event + reducer
class Count extends State.make('count', S.Number) {}
class Counters extends StateGroup.make(Count) {}
class Bumped extends Event.make('Bumped', S.Struct({ by: S.Number })) {}
class Bump extends Reducer.make('Bump', { states: [Count], events: [Bumped] }) {}
// contract + composition
class CounterUi extends ui('Counter')<{
props: { count: number }
events: { bump: Trigger<{ by: number }> }
}>() {}
class Counter extends Composition.make('Counter', { ui: CounterUi, states: [Count] }) {}
const logic = Layer.mergeAll(
StateGroup.live(Counters, { count: 0 }),
Reducer.live(Bump, (n, e) => n + e.by),
Composition.live(Counter, function* () {
const count = yield* StateGroup.select(Counters, 'count')
const bump = yield* Event.trigger(Bumped)
return (yield* CounterUi)({ count }, { bump })
}),
)
// a DOM presentation (rendered by @playfast/reform-react)
const view = provide(CounterUi, Ui.make(CounterUi, ({ count }, _s, { bump }) =>
<button onClick={() => bump({ by: 1 })}>count: {count}</button>
))
export const CounterScene = scene(Counter, { provide: [Engine, logic, view] })Mount it with @playfast/reform-react, or prove it headlessly with @playfast/reform-proof — the same CounterScene value.
The reform family
| Package | Role |
| ------------------------------------------------------------------------------------------------ | ------------------------------------ |
| @playfast/reform | Renderer-neutral core (this package) |
| @playfast/reform-react | React / DOM host |
| @playfast/reform-react-native | React Native host |
| @playfast/reform-forms | Headless form state |
| @playfast/reform-forms-react | Typed JSX mapping for forms |
| @playfast/reform-proof | Headless testing toolkit |
| @playfast/reform-eslint-plugin | Lint rules for reform conventions |
License
MIT
