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

@airlib/form

v1.2.1

Published

Reactive and declarative, framework-agnostic form engine powered by Zod schemas. Validation, dirty tracking, cross-field matching, and submission lifecycle built on AIR Stack.

Downloads

774

Readme

AIR Form

AIR Form is a reactive, framework-agnostic form engine powered by Zod schemas.

AIR Stack Integration

To use AIR Form inside a reactive component, create a typed form factory outside the component, and initialize the form state within the component's setup phase.

import { setup, render } from '@anchorlib/react';
import { formState } from '@airlib/form';
import { z } from 'zod';

const userSchema = z.object({
  name: z.string().min(3, 'Name is too short'),
  age: z.number().min(18, 'Must be an adult'),
});

export const UserForm = setup((props) => {
  const form = formState(userSchema, { 
    value: { name: '', age: 0 } 
  });

  return render(() => (
    <form>
      {/* UI logic goes here */}
    </form>
  ));
});

The formState function creates a reactive store typed to the schema and integrated with the reactive rendering cycle.

Field Selection

To access a specific field within the form, use the formField function or the .field() method.

import { formField } from '@airlib/form';

export const UserForm = setup((props) => {
  const form = formState(userSchema, { value: { name: '', age: 0 } });
  const name = formField('name');

  return render(() => (
    <div>
      <input 
        value={name.value} 
        onInput={(e) => { name.value = e.currentTarget.value; }} 
      />
      {!name.valid && <span>{name.error}</span>}
    </div>
  ));
});

Each field provides reactive access to value, error, valid, changed, touched, matched, and disabled.

Touched Tracking

Fields are marked as touched when their value is first mutated. This happens inside the form's setter, requiring no manual onBlur handlers.

const name = formField('name');

// Before any mutation
name.touched; // false

// After value change
name.value = 'Alice';
name.touched; // true — stays true until reset

Touched state persists even if the value reverts to its original. A field can be changed: false but touched: true — "you were here."

Cross-Field Matching

To validate that one field equals another, pass a match parameter to formField.

const confirm = formField('confirmPassword', 'password');

confirm.matched; // true when values are equal
confirm.valid;   // schema validation only — independent of matched

For custom cross-field logic beyond equality, pass a function.

const endDate = formField('endDate', (form) =>
  form.fields['endDate'] > form.fields['startDate']
);

The function runs inside an effect, so Anchor tracks which fields it reads and re-evaluates when any of them change.

valid and matched are separate signals. valid is schema-only. matched is match-only. The view layer composes them however it wants.

Input Controllers

To bind a field to a UI input, use the .input() method to generate an input controller.

export const UserForm = setup<UserFormProps>((props) => {
  const form = formState(userSchema, props);
  
  const name = form.field('name').input({ type: 'text' });
  const age = form.field('age').input({ type: 'number' });

  return render(() => (
    <form>
      <input 
        type={name.type} 
        name={name.name}
        value={name.value}
        disabled={name.disabled}
        onInput={(e) => { name.value = e.currentTarget.value; }}
        onBlur={() => { name.settled(); }}
      />
      
      <input 
        type={age.type} 
        name={age.name}
        value={age.value}
        disabled={age.disabled}
        onInput={(e) => { age.value = e.currentTarget.value; }}
        onBlur={() => { age.settled(); }}
      />
    </form>
  ));
});

The input controller handles two-way data binding, string parsing, event synchronization, and inherits the form's pending state via the disabled property to lock inputs during network submission.

Form Context

To build composable input components without passing props, use the formField API to inherit the form context.

import { formField, FormInputType } from '@airlib/form';
import { setup, render } from '@anchorlib/react';

export const TextInput = setup<{ name: string, label: string, type?: FormInputType }>((props) => {
  const input = formField<string>(props.name).input(props);

  return render(() => (
    <div className="field-group">
      <label>{props.label}</label>
      <input 
        type={input.type}
        name={input.name}
        value={input.value}
        disabled={input.disabled}
        onInput={(e) => { input.value = e.currentTarget.value; }}
        onBlur={() => { input.settled(); }}
      />
      {!input.valid && <span className="error">{input.error}</span>}
    </div>
  ));
});

The formField function discovers the closest form provider in the component tree.

Form Submission

The .submit() method handles the complete submission lifecycle. It executes the provided handler, tracks the network status (IDLE, PENDING, SUCCESS, ERROR), prevents concurrent race conditions by locking the form, and maps its pending status down to the disabled state of all connected input fields.

On a successful submission, the form cleans up its dirty state (dropping form.changed to false) making the submitted data the new baseline.

export const ProfileForm = setup(() => {
  const form = formState(userSchema, { value: { name: '', age: 0 } });

  const saveProfile = async (data: { name: string, age: number }) => {
    await fetch('/api/user', { method: 'POST', body: JSON.stringify(data) });
  };

  return render(() => (
    <form>
      {/* ... Form inputs ... */}

      <button 
        disabled={!form.canSubmit}
        onClick={(e) => {
          e.preventDefault();
          form.submit(saveProfile);
        }}
      >
        {form.pending ? 'Saving...' : 'Save Profile'}
      </button>

      {form.status === 'error' && (
        <div className="error-banner">{form.error?.message}</div>
      )}
    </form>
  ));
});

By relying on .submit(handler), developers avoid boilerplate loading-states, manual network try/catch blocks, and dirty-state reset procedures across the application.