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

@pyreon/form

v0.34.0

Published

Signal-based form management for Pyreon

Readme

@pyreon/form

Signal-based form management — fields, validation, arrays, and context.

Every field on a Pyreon form is its own Signal<T> (one each for value, error, touched, dirty), so templates only re-run for the slice they read. Two complementary APIs: the classic useForm({ initialValues, validators, onSubmit }), and the composable field('name', default, validator) → useForm({ fields }) + useField('name') that infers FormState from a field array and skips prop drilling via <Form of={form}> / FormProvider. Pairs with @pyreon/validation for Zod/Valibot/ArkType schema integration.

Install

bun add @pyreon/form @pyreon/core @pyreon/reactivity

Quick start

import { useForm } from '@pyreon/form'

function LoginForm() {
  const form = useForm({
    initialValues: { email: '', password: '' },
    validators: {
      email: (v) => (!v.includes('@') ? 'Invalid email' : undefined),
      password: (v) => (v.length < 8 ? 'Too short' : undefined),
    },
    validateOn: 'blur',
    onSubmit: async (values) => {
      await fetch('/api/login', { method: 'POST', body: JSON.stringify(values) })
    },
  })

  return (
    <form onSubmit={form.handleSubmit}>
      <input type="email" {...form.register('email')} />
      <input type="password" {...form.register('password')} />
      <button type="submit">Login</button>
    </form>
  )
}

Composable fields + <Form> (recommended)

Define fields once, infer the form shape automatically, and read from context instead of prop drilling:

import { field, useForm, useField, Form, Submit } from '@pyreon/form'

const email = field('email', '', (v) => (!v.includes('@') ? 'Invalid' : undefined))
const password = field('password', '', (v) => (v.length < 8 ? 'Too short' : undefined))
const confirm = field('confirmPassword', '', (v, all) =>
  v !== all.password ? 'Mismatch' : undefined,
)

const form = useForm({
  fields: [email, password, confirm],
  onSubmit: (values) => {
    /* { email: string; password: string; confirmPassword: string } */
  },
})

function EmailInput() {
  const f = useField<string>('email')
  return (
    <>
      <input {...f.register()} />
      {() => (f.showError() ? <span>{f.error()}</span> : null)}
    </>
  )
}

;<Form of={form}>
  <EmailInput />
  <PasswordInput />
  <Submit>Login</Submit>
</Form>

<Form of={form}> renders a <form> element, binds onSubmit to form.handleSubmit, and provides the form via context. <Submit> auto-disables during form.isSubmitting().

useForm(options)

| Option | Type | Description | | ---------------- | -------------------------------------------- | ----------------------------------------------------------------------- | | initialValues | TValues | Required when using the value-form. Drives field keys + types. | | fields | FieldDefinition[] | Required when using the composable form. Drives TValues via inference. | | onSubmit | (values: TValues) => void \| Promise<void> | Submit handler — receives validated values | | validators | Partial<Record<keyof TValues, ValidateFn>> | Per-field validators; signature (value, allValues) => string \| undefined | | schema | SchemaValidateFn<TValues> | Whole-form schema validator (from @pyreon/validation adapters) | | validateOn | 'blur' \| 'change' \| 'submit' | When to validate. Default: 'blur' | | debounceMs | number | Debounce delay for validators (especially async) |

Returns FormState<TValues> with per-field Signals (value, error, touched, dirty), form-level signals (isSubmitting, isValidating, submitCount, submitError), computed accessors (isValid(), isDirty(), values(), errors()), and handlers (handleSubmit, register, validate, reset, setFieldValue, setFieldError, setErrors, clearErrors, resetField).

// Bind text input:
<input {...form.register('email')} />

// Bind checkbox (boolean field):
<input type="checkbox" {...form.register('remember', { type: 'checkbox' })} />

// Bind number input (auto-parses to number):
<input type="number" {...form.register('age', { type: 'number' })} />

useField(name) / useField(form, name)

Extracts a single field's state with computed helpers (hasError, showError). Two overloads:

// Context form — reads form from <Form>/<FormProvider>. Accepts generic.
const f = useField<string>('email')

// Explicit form — pass a known FormState.
const f = useField(form, 'email')

Returns UseFieldResult<T>:

| Property | Type | Description | | ------------- | ----------------------------------------------- | -------------------------------------------- | | value | Signal<T> | Field value | | error | Signal<ValidationError> | string \| undefined | | touched | Signal<boolean> | True after first blur | | dirty | Signal<boolean> | True when value differs from initial | | hasError | Computed<boolean> | True when an error string exists | | showError | Computed<boolean> | True when touched() AND hasError() | | setValue | (v: T) => void | Programmatic set | | setTouched | (b: boolean) => void | Mark touched | | reset | () => void | Reset to field's initial value | | register | () => FieldRegisterProps<T> | Spreadable input props |

showError is the right gate for displaying error messages — it stays silent until the user has blurred at least once, even when validateOn: 'change'.

useFieldArray(initial?)

Dynamic arrays with stable monotonic keys for keyed rendering.

const tags = useFieldArray<string>(['typescript'])
tags.append('pyreon')
tags.prepend('signals')
tags.insert(1, 'reactive')
tags.move(0, 2)
tags.swap(0, 1)
tags.update(0, 'updated')
tags.remove(0)
tags.replace(['a', 'b', 'c'])
tags.values() // string[]
tags.length() // number
tags.items() // FieldArrayItem<string>[] — { key: number, value: Signal<T> }

Each item carries a key: number — monotonically increasing, assigned at insert time. Render with <For each={tags.items()} by={(i) => i.key}> so reordering / insertion preserves component identity (and input focus). Index-based keys defeat the stable-key design.

useWatch(form, name?)

Typed overloads for reactive field watchers:

const email = useWatch(form, 'email') // Signal<string>
const [first, last] = useWatch(form, ['firstName', 'lastName']) // tuple of Signals
const all = useWatch(form) // Computed<TValues>

Single-field form returns the underlying Signal<T> directly (no wrapper). Prefer the narrowest form — watching the whole form re-runs your effect on every field change.

useFormState(form, selector?)

Computed summary of form-level state. Pass a selector to narrow the tracked subset.

const canSubmit = useFormState(form, (s) => s.isValid && !s.isSubmitting && s.isDirty)
// canSubmit() updates only when those 3 booleans flip

FormStateSummary fields: isSubmitting, isValidating, isValid, isDirty, submitCount, submitError, touchedFields, dirtyFields, errors. Without a selector the computed re-derives on ANY summary field change — always pass a selector for UI-bound computeds.

FormProvider / useFormContext() / <Form> / <Submit>

<FormProvider form={form}>
  <DeepInputs />
</FormProvider>

// Inside any descendant:
function DeepInputs() {
  const form = useFormContext<{ email: string; password: string }>()
  return <input {...form.register('email')} />
}

<Form of={form}> is sugar: renders <form onSubmit={form.handleSubmit}> + <FormProvider>. <Submit> reads form.isSubmitting() from context and auto-disables.

Pass the <TValues> generic on useFormContext — otherwise TypeScript infers Record<string, unknown> and field names lose type narrowing.

Server-side errors

const form = useForm({
  initialValues: { email: '' },
  onSubmit: async (values) => {
    const res = await fetch('/api/register', { method: 'POST', body: JSON.stringify(values) })
    if (!res.ok) {
      const errors = await res.json()
      form.setErrors(errors) // { email: 'Already registered' }
    }
  },
})

setFieldError / setErrors do NOT touch touched state — server errors display immediately regardless of blur status.

Schema validation

Pair with @pyreon/validation adapters. Per-field validators run first; schema errors merge after.

import { zodSchema } from '@pyreon/validation/zod'
import { z } from 'zod'

const form = useForm({
  initialValues: { email: '', age: 0 },
  schema: zodSchema(z.object({ email: z.string().email(), age: z.number().min(13) })),
  onSubmit: async (values) => {
    /* ... */
  },
})

Devtools

import { formRegistry } from '@pyreon/form/devtools'
// WeakRef registry of live form instances — tree-shakeable.

Gotchas

  • validateOn defaults to 'blur', not 'change' — users aren't scolded mid-keystroke. Pair 'change' with debounceMs for async validators.
  • Async validators are version-tracked — stale results are discarded if the user types faster than the validator resolves. form.isValidating is true while any field has a pending async validation; gate the submit button on it.
  • Mutating initialValues after creation has no effect — they're read once at setup. Use setFieldValue for programmatic updates.
  • form.fields[name].value is Signal<T> — call it: form.fields.email.value(). Reading without calling captures the signal reference, not the value.
  • handleSubmit calls preventDefault() — wire it as <form onSubmit={form.handleSubmit}> or call with no argument for programmatic submit.
  • schema runs AFTER per-field validators — both error sources merge; a schema error can override a field-level error on the same key.
  • FormProvider doesn't support nesting — the inner shadows the outer. For multi-form pages use separate sibling providers.
  • register() results are memoized per field+type combo — calling register('email') twice returns the same object.
  • useFormState(form) without a selector re-derives on every state change. Always pass a selector.
  • Don't pass a signal-read into initialValuesinitialValues: { name: user() } snapshots once. Use setFieldValue reactively. Caught by the opt-in lint rule pyreon/no-signal-in-form-initial-values.

Documentation

Full docs: pyreon.dev/docs/form (or docs/src/content/docs/form.md in this repo).

License

MIT