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

form-graph

v0.5.0

Published

Branch-routed forms with one definition, both sides: a contract engine for forms whose shape depends on their own values.

Readme

form-graph

Branch-routed forms with one definition, both sides.

form-graph is a contract engine for forms whose shape depends on their own values — picking one option changes which fields exist, with defaults, per-branch memory, and coupling rules. You declare the fields as a graph; TypeScript infers the discriminated union from it; the same definition IS the client store and parses raw input on the server. No second schema, no drift.

Documentation · Live demos

import { z } from 'zod';
import { defineGraph } from 'form-graph';
import { slider, enumOf } from 'form-graph/defs';

// A field is ONE definition — schemas, default, meta, conditions, together.
// Conditional fields are functions of (c, ext); null = doesn't exist this pass.
const graph = defineGraph<{ maxSteps: number }>()
  .field('mode', enumOf({
    options: [
      { value: 'create', label: 'Create' },
      { value: 'upscale', label: 'Upscale' },
    ],
    default: 'create',
  }))
  .field('prompt', {
    input: z.string().optional(),                    // lenient: storage, URLs, raw input
    output: z.string().min(1, 'Prompt is required'), // strict: submit / server parse
    default: '',
  })
  .field('steps', ({ mode, _ext }) =>
    mode === 'create' ? slider({ min: 1, max: _ext.maxSteps, default: 25 }) : null)
  .field('scale', ({ mode }) =>
    mode === 'upscale' ? slider({ min: 2, max: 4, default: 2 }) : null);

// The graph IS the form — the runtime lives on the definition.
const store = graph.createStore({ ext: { maxSteps: 50 } });

// Server: the same pipeline over raw input.
const result = graph.parse(rawBody, { maxSteps: 50 });

Why it exists

General-purpose form libraries assume a static shape. A value-dependent form breaks them: the branching leaks into every layer as its own copy of the same conditional — a watch() here to hide a field, an if in the validator to skip it, an effect to reset it, a guard in the submit handler. form-graph gives the shape exactly one home — the resolver — and everything else derives from it: what the UI renders, what the output contains, what validation runs over, and what the types say.

What it does that form libraries don't

  • The union is inferred, not annotated. Each resolver branch returns a different shape; consumers narrow with plain if/switch. Type cost scales with branch width, not nesting depth — measured linear at 120 branches.
  • Identical output client and server. parse() runs the same resolve → validate pipeline the store runs.
  • Scoped, persistent intent. Choices are remembered per scope (steps@flux vs steps@sd) and survive branch switches — return to a branch and your values are back. The persisted format has supported readers, not ad-hoc key parsing.
  • Lenient boundaries, strict output. Dual schemas per field: stored/remixed/raw values parse leniently and fall back to defaults; submit validates strictly, reporting every issue with its path. Nothing schema-shaped runs on the keystroke path.
  • Auditable corrections. A definition's correct: (v) => ({ value, reason }) policy replaces a value the system invalidated — a visible statement in the definition, not a hidden hook — and the reason rides to the server as a note on every parse (failures included) and onto the field's snapshot for inline display.
  • Dynamic contracts in zod's own vocabulary. refine: (s) => s.refine(...) narrows a field's output schema under the current conditions, deps-cached so typing never constructs a schema, with a per-field error at validate/parse — live for written fields under revalidate: 'touched'.
  • Cross-field couplings without effect soup. Rules are records keyed by the triggering field, run in one ordered pass per set() — cycles are unrepresentable, not detected.
  • Collections as a combinator. list(key, memberGraph, {min, max}) mounts a member graph per element — each row with its own branches, computeds, errors, and persistence, addressed by stable ids (runs[a1b2].engine). A root computed aggregates the rows like any upstream value; render isolation (an edit wakes only its row, a reorder only the shell) is pinned by render-count tests on both bindings.
  • Wizards without a step registry. validate(step.keys) judges only that step's fields — a key list declared next to the step's UI, inactive branch arms vacuously valid, a whole list as a single gate. The graph never learns what a step is.
  • Async validity, sync engine. setError(key, {message}) is the door for server verdicts (an audit refusal, a failed cost check): live on the field, fails validate, cleared the moment the user writes — never persisted.
  • RHF-grade form UX, opt-in. isDirty/dirtyFields (the intent map IS the dirty tracker), focusFirstError (first offender in declaration order, wizard-step scoped), and revalidate: 'touched' — live errors for fields the user has written, pristine fields never scolded, measured at ~6µs worst-case on a 35-field form.
  • Full recompute, isolated renders. Every change recomputes the whole snapshot; a reference-preserving diff means only controls whose data moved re-render.
  • Framework-free core, first-class bindings. All semantics live in the core store; the React and Svelte bindings are each a thin bridge over the same per-field subscriptions.

Framework bindings

Svelte (form-graph/svelte):

<script lang="ts">
  import { typedFields, Field } from 'form-graph/svelte';
  const store = form.createStore({ ext });
  const f = typedFields(store); // every key, typed from the form — no annotations
</script>

<!-- Placed flat: the FORM decides whether this renders -->
<Field {store} name="steps">
  {#snippet children(snap, setValue)}
    <input type="range" min={snap.meta.min} max={snap.meta.max}
      value={snap.value} oninput={(e) => setValue(Number(e.currentTarget.value))} />
  {/snippet}
</Field>

React (form-graph/react):

import { useForm, useTypedField, Controller } from 'form-graph/react';

const store = useForm(form, { ext });
const steps = useTypedField(store, 'steps'); // typed from the form

<Controller name="steps" render={({ value, meta, onChange }) => (
  <Slider min={meta.min} max={meta.max} value={value} onChange={onChange} />
)} />

Persistence is one line — JSON web storage, debounced writes, flush on tab close, SSR-safe:

import { persistedStorage } from 'form-graph';
const store = form.createStore({ ext, storage: persistedStorage('my-form') });

Entry points

| Import | Contents | | --- | --- | | form-graph | core: defineGraph, branch, list, store (incl. setError, scoped validate, store.list, isDirty), focusFirstError, introspection, persistedStorage, intent readers | | form-graph/svelte | typedFields, <Field>, field, formState, list, elementPath, syncExt | | form-graph/react | useForm, useField, useTypedField, useFormState, Controller, MultiController, createTypedController, FormProvider, useList, <ListElement> | | form-graph/defs | the definition helpers: sliders, enums, text, booleans — schemas cached automatically |

Docs and demos

The full documentation and a ladder of live demos are published from this repository:

  • Docs — bkdiehl.github.io/form-graph/docs: getting started, core concepts, the graph model, rules, server parsing, both bindings.
  • Demos — bkdiehl.github.io/form-graph/demo: from a teaching form up to a production-scale generation form with dozens of branches, verified against its legacy implementation by a differential parity harness.

Locally, pnpm dev serves the same site.

Status

Pre-1.0. The API has been through several deliberate revisions and is settling, but every release before 1.0 may break it. Battle-testing is ongoing against a large production form (40+ branch families); the core engine, both bindings, and the persistence layer are covered by ~430 tests including compile-time type assertions, render-isolation contracts on both frameworks, and a differential parity suite against the production implementation it replaces.

Engineering history — the design decisions, measurements, and dead ends — lives in docs/DEVLOG.md.