npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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

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.

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