@playfast/reform
v0.0.9
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/react, @playfast/react-native) turns a closed scene into a live tree, and @playfast/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)<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
disabled?, // (inputs) => boolean — gate the query off (Idle); gatable calcs only
})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, no value yet |
| 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)<Comp>()— a hole a parent composition declares (slots: { body: BodySlot }) and fills withprovide(BodySlot, ChildComposition)(or aFeature). 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',
load: lazyImport(() => import('./reports.module')),
placeholder: { loading: SpinnerComp, failed: RetryComp },
}) {}A feature shares the app's engine but gets its own scope — mountFeature(binding, engineContext) loads it, builds its layer, dispatches its boot events, and disposes everything when the scope closes. Fill a slot with one via provide(SomeSlot, Reports); the host paints the placeholders while it loads.
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
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/react to mount or to @playfast/proof to assert. seedScene overlays seed values onto already-closed layers (used by the dev tool to preview alternative initial state).
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 (DuplicateRegistration, FeatureLoadFailed, InvalidProvideTarget, SlotRenderingUnavailable, UnknownGroupState, AsyncReducer); 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/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/react, or prove it headlessly with @playfast/proof — the same CounterScene value.
The reform family
| Package | Role |
| --- | --- |
| @playfast/reform | Renderer-neutral core (this package) |
| @playfast/react | React / DOM host |
| @playfast/react-native | React Native host |
| @playfast/forms | Headless form state |
| @playfast/forms-react | Typed JSX mapping for forms |
| @playfast/proof | Headless testing toolkit |
| @playfast/eslint-plugin | Lint rules for reform conventions |
License
MIT
