npm.io
0.0.11 • Published 4d ago

@playfast/reform

Licence
MIT
Version
0.0.11
Deps
0
Size
450 kB
Vulns
0
Weekly
0

@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.

npm license built with Effect


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 effect

effect is a peer dependency. react is an optional peer (only the React-facing primitives need it).

Contents


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 before Normal-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, a Calc, an AsyncCalc, a RemoteState) and accept an optional invalidateBy key projection that bounds recompute to a value-equal key change.
  • Notifications coalesce into a single microtask flush, which the host's useSyncExternalStore bridge 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 / AnySourceSource<N, A> is the structural shape ({ name, store }) that StateToken, Calc, AsyncCalc, and RemoteState all satisfy, so any of them can feed a calc's inputs. 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 value

inputs 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
})
  • error omitted ⇒ the query is infallible (no Error arm). alwaysOn: true ⇒ no Idle arm and disabled is rejected.
  • Reading yield* MyAsyncCalc yields an AsyncData<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 overlaid AsyncData<A, E, Gated> (server truth with pending intents applied).
  • Board.truth → the un-overlaid query lifecycle (a Source), for chrome that must show raw server state.
  • Board.pending → read-only Source of ReadonlyArray<PendingIntent<I>> ({ opId, intent, status: 'sending' | 'confirmed' }).
  • Board.Failed → a public Event carrying FailedIntent ({ 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   // BoundaryState

yield* BootBoundary yields a BoundaryState:

  • BoundaryPending — some source is on its first load.
  • BoundaryReady — every source has settled (Success, even refetching, or a deliberately gated Idle).
  • BoundaryErrored — a source errored before first value; carries errors: 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 declares ui plus the states / calcs / events / slots it touches (all reflectable).
  • Composition.live(comp, fn*) — the generator reads sources and returns a Node (the view applied to props/events).
  • slot(name)<Comp>() — a hole a parent composition declares (slots: { body: BodySlot }) and fills with provide(BodySlot, ChildComposition) (or a Feature). Children render through the host.
  • provide(...) — binds a UI presentation to a contract, or fills a slot with a composition / feature. Returns a Layer.

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 schedulerNotifications / 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