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

@streetui/forms

v3.0.2

Published

StreetUI forms — reactive form state, validation, and submission lifecycle built on signals

Readme

@streetui/forms

A reactive form model and a small synchronous validation system, built entirely on @streetui/state signals. There is no second state system: every piece of form state — values, errors, touched, dirty, valid, and the submission lifecycle — is a signal or a derived signal, so it composes with the renderer's existing reactive bindings and needs no special integration.

# part of the StreetUI monorepo — no separate install

The form model

createForm<T>() takes typed initial values, optional per-field validators, and an onSubmit handler. T must extend Record<string, string> (HTML input values are strings), and every reactive accessor is typed against it.

import { createForm, required, email, minLength } from '@streetui/forms';

interface SignupValues {
  name: string;
  email: string;
  password: string;
  [key: string]: string;
}

const form = createForm<SignupValues>({
  initialValues: { name: '', email: '', password: '' },
  validators: {
    name: required('Name is required'),
    email: [required('Email is required'), email()],   // ordered — first error wins
    password: [required(), minLength(8, 'At least 8 characters')],
  },
  onSubmit: async (values) => {
    await createAccount(values);   // any async function; may throw
  },
});

Form-level reactive state

| Accessor | Type | Meaning | |---|---|---| | form.values | ReadonlySignal<T> | Current values of every field. | | form.errors | ReadonlySignal<Partial<Record<keyof T, string>>> | Only the fields that currently have an error. | | form.touched | ReadonlySignal<Partial<Record<keyof T, boolean>>> | Per-field touched flags. | | form.dirty | ReadonlySignal<boolean> | True if any field differs from its initial value. | | form.valid | ReadonlySignal<boolean> | True when every field passes validation. | | form.submitting | ReadonlySignal<boolean> | True while onSubmit is in flight. | | form.submitted | ReadonlySignal<boolean> | True after a successful submit. | | form.status | ReadonlySignal<'idle' \| 'submitting' \| 'success' \| 'error'> | The submission lifecycle. | | form.submitError | ReadonlySignal<unknown> | Whatever onSubmit threw, if anything. |

Because these are the same signals used everywhere else in StreetUI, they drop straight into DSL bindings and derived().


Fields and input binding

form.field(name) returns the reactive state and setters for one field. The field's value is a writable Signal<string> — pass it directly to the DSL's input({ bind }). That is the one binding the renderer already wires: typing updates form state, and programmatic updates update the input. No duplicate event listeners are added.

const emailField = form.field('email');

page.input({ bind: emailField.value, type: 'email' });
page.when(
  derived(() => emailField.touched.get() && emailField.error.get() !== undefined),
  (err) => err.text(derived(() => emailField.error.get() ?? ''), { role: 'alert' }),
);

A Field exposes value (writable signal), error, touched, dirty, valid (read-only signals), and the imperative helpers setValue(), markTouched(), and reset(). Errors are computed reactively from the validators, so surfacing them is a matter of reading field.error — usually gated on field.touched so a pristine field is not flagged before the user has interacted with it.


Submission lifecycle

form.submit() marks every field touched (so validation errors become visible), then:

  • if the form is invalid, it stays idle and returns without calling onSubmit — the now-visible field errors tell the user what to fix;
  • if valid, it transitions idle → submitting, awaits onSubmit(values), and settles on success or — if onSubmit throws — error, capturing the thrown value in form.submitError.

Wire it to both the button and the native form submit event; the renderer's event system already calls preventDefault() for submit events, so no manual handling is needed:

page.form('signup', (fb) => {
  // ...fields...
  fb.button(
    derived(() => (form.submitting.get() ? 'Creating…' : 'Create account')),
    { disabled: form.submitting, onClick: () => void form.submit() },
  );
}, { onSubmit: () => void form.submit() });

Validators

A Validator is just (value: string) => string | undefined — return a message to fail, undefined to pass. Built-ins cover the common cases; anything else is a plain function.

| Validator | Fails when | |---|---| | required(message?) | the trimmed value is empty | | minLength(n, message?) | shorter than n characters | | maxLength(n, message?) | longer than n characters | | email(message?) | a non-empty value is not a plausible email | | pattern(regex, message?) | a non-empty value does not match regex |

Per-field validators run in order and the first error wins, so list required first for mandatory fields. A custom validator needs no wrapper:

const noSpaces: Validator = (v) => (/\s/.test(v) ? 'No spaces allowed' : undefined);

createForm({ initialValues: { handle: '' }, validators: { handle: [required(), noSpaces] } });

Async validation

Async validation is intentionally not part of this core, to keep the validity model synchronous and predictable. Layer it on with @streetui/state's resource(): kick off a resource on value change and surface resource.error alongside field.error. This keeps network-driven checks out of the synchronous valid computation.


Lifecycle

createForm() allocates field subscriptions and derived signals. Call form.dispose() when the owning subtree is torn down — inside a route this is ctx.onCleanup(() => form.dispose()) — to release every subscription and derived signal. form.reset() restores initial values and clears errors, touched, dirty, and submission state without disposing.

A complete, runnable form (real HTTP submit, validation, error state, SSR + hydration, i18n'd messages) lives in examples/streetui-account.