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

@fransek/form

v0.7.0

Published

Simple form management without sacrificing control.

Downloads

494

Readme

@fransek/form

Simple form management without sacrificing control.

Installation

npm install @fransek/form

Overview

@fransek/form is a headless form library for React. It manages field state and validation without rendering any UI — you stay in full control of markup and styling.

The entire API is three components and three utilities:

| Export | Description | | ----------------------------------------- | ------------------------------------------------------------------------------------------------- | | <Form> | Wraps a <form>, coordinates submit-time validation across all fields | | <Field> | Headless field component. Manages validation lifecycle via a render prop | | <FormState> | Reactively exposes aggregate form state (isValid/isTouched/isDirty/isValidating/isSubmitting/canSubmit) via a render prop | | createFieldState(initialValue) | Creates the initial FieldState for a field | | validate(state, validators, mode?) | Runs synchronous validators outside of a <Field> | | validateAsync(state, validators, mode?) | Runs async validators outside of a <Field> |

Quick Start

import { createFieldState, Field, Form } from "@fransek/form";
import { useState } from "react";

function required(value: string) {
  if (!value) return "This field is required";
}

export function MyForm() {
  const [name, setName] = useState(createFieldState(""));

  return (
    <Form
      onSubmit={async ({ event, validate, commit }) => {
        event.preventDefault();
        if (await validate()) {
          console.log("submitted:", name.value);
        }
        commit();
      }}
    >
      <Field
        state={name}
        onChange={setName}
        validation={{ onChange: required }}
      >
        {({ value, handleChange, handleBlur, ref, errorMessage }) => (
          <div>
            <input
              value={value}
              onChange={(e) => handleChange(e.target.value)}
              onBlur={handleBlur}
              ref={ref}
            />
            {errorMessage && <p>{errorMessage}</p>}
          </div>
        )}
      </Field>

      <button type="submit">Submit</button>
    </Form>
  );
}

Field State

Every field is backed by a FieldState<T> object. Create it with createFieldState:

const state = createFieldState(""); // FieldState<string>
const state = createFieldState<string | null>(null); // FieldState<string | null>
const state = createFieldState<string[]>([]); // FieldState<string[]>

FieldState<T> has the following shape:

interface FieldState<T> {
  value: T;
  errorMessage: React.ReactNode; // undefined when valid
  isTouched: boolean; // true after the field has been blurred
  isDirty: boolean; // true after the value has changed
  isValid: boolean;
  isValidating: boolean; // true while an async validator is running
}

Validation

Validators

A synchronous validator returns an error message (any truthy React.ReactNode) or a falsy value when valid:

const required = (value: string) => (!value ? "Required" : undefined);

An async validator returns a Promise of the same:

const checkAvailable = async (value: string) => {
  const taken = await api.check(value);
  return taken ? "Already taken" : undefined;
};

Validation triggers

Pass validators to <Field> via the validation prop. Each key maps to a different trigger:

<Field
  state={state}
  onChange={setState}
  validation={{
    onChange: required, // sync, runs on every change
    onChangeAsync: checkAvailable, // async, debounced (default 500 ms)
    onBlur: required, // sync, runs on blur
    onBlurAsync: checkAvailable, // async, runs on blur
    onSubmit: required, // sync, runs on form submit
    onSubmitAsync: checkAvailable, // async, runs on form submit
  }}
/>

Validation mode

By default, errors are shown only after the field has been both touched and changed ("touchedAndDirty"). Override this on <Form> or per <Field>:

| Mode | When errors appear | | ------------------- | ------------------------------------------- | | "touchedAndDirty" | After blur and a value change (default) | | "touchedOrDirty" | After blur or a value change | | "touched" | After blur only | | "dirty" | After a value change only |

// Set a default for all fields
<Form validationMode="touched">

// Override for a specific field
<Field validationMode="dirty" ...>

Validation dependencies

Use dependency arrays when a validator depends on values outside the field itself. Dependency arrays are explicit: they control when a field should revalidate because related external state changed. When one of those external values changes, the field is revalidated using the validators whose dependency arrays changed.

<Field
  state={repeatPassword}
  onChange={setRepeatPassword}
  validation={{
    onChange: (value) =>
      value !== password.value ? "Passwords do not match" : undefined,
    onChangeDependencies: [password.value],
  }}
>
  {(props) => (
    <input
      value={props.value}
      onChange={(e) => props.handleChange(e.target.value)}
      onBlur={props.handleBlur}
      ref={props.ref}
    />
  )}
</Field>

In this example, changing password reruns the repeat-password check even when the repeat-password field value itself stays the same.

Form

<Form> is a thin wrapper around <form> that provides context to child fields and coordinates submit-time validation.

<Form
  onSubmit={async ({ event, validate, commit }) => {
    event.preventDefault();
    const isValid = await validate();
    if (isValid) { /* ... */ }
    commit({ focusFirstError: true, scrollOffset: 100 });
  }}
  validationMode="touchedAndDirty" // default for all fields
  debounceMs={500}                  // default async debounce for all fields
  skipAsyncValidationOnSubmit       // optional default for all fields
>

validate evaluates every registered field using its onChange, onBlur, and onSubmit validators and returns whether they pass. By default it also runs onChangeAsync and onBlurAsync; set skipAsyncValidationOnSubmit on <Form> (default for all fields) or on a specific <Field> to skip those two async hooks during submit validation. onSubmitAsync still runs. Provide the matching *Dependencies array whenever a validator also depends on external state and should be revalidated when that state changes. commit then applies pending validation state updates, runs onCommit validators, and optionally focuses the first invalid field.

onSubmit may be async. When the handler returns a promise, the form's isSubmitting aggregate state (see Aggregate Form State) is true while that promise is pending and resets to false when it settles — even if it rejects.

Aggregate Form State

<FormState> reactively derives the combined state of every field in the surrounding <Form> and passes it to a render prop. It re-renders only when the aggregate changes — typing that doesn't flip a flag does not cause extra renders.

import { Field, Form, FormState } from "@fransek/form";

<Form
  onSubmit={async ({ event, validate, commit }) => {
    event.preventDefault();
    if (await validate()) {
      await save(); // isSubmitting is true while this runs
    }
    commit();
  }}
>
  <Field state={name} onChange={setName} validation={{ onChange: required }}>
    {(props) => <input {/* ... */} />}
  </Field>

  <FormState>
    {({ canSubmit, isSubmitting }) => (
      <button type="submit" disabled={!canSubmit}>
        {isSubmitting ? "Submitting…" : "Submit"}
      </button>
    )}
  </FormState>
</Form>;

The render prop receives a FormAggregateState:

interface FormAggregateState {
  isValid: boolean; // every field is valid (true when there are no fields)
  isTouched: boolean; // at least one field has been touched
  isDirty: boolean; // at least one field value has changed
  isValidating: boolean; // at least one field is running async validation
  isSubmitting: boolean; // an async onSubmit handler is in flight
  canSubmit: boolean; // isValid && !isSubmitting && !isValidating
}

canSubmit intentionally does not require the form to be dirty, so a pristine but valid form (e.g. one with only optional fields) is submittable. Compose canSubmit && isDirty yourself if you want to block submitting an unchanged form.

Render Props

The children function of <Field> receives a FieldRenderProps<T> object:

interface FieldRenderProps<T> extends FieldState<T> {
  handleChange: (value: T) => void; // call on input change
  handleBlur: () => void; // call on input blur
  ref: (el: HTMLElement | null) => void; // attach to the root input element
}

Always attach ref to enable focusFirstError on submit.

Validate a FieldState Manually

For cross-field validation inside a form, prefer *Dependencies on the relevant <Field>. That keeps revalidation attached to the field that owns the error state:

<Field
  state={repeatPassword}
  onChange={setRepeatPassword}
  validation={{
    onChange: (value) =>
      value !== password.value ? "Passwords do not match" : undefined,
    onChangeDependencies: [password.value],
  }}
>

Use validate or validateAsync when you need to validate a FieldState manually outside a <Field> lifecycle — for example, in custom state orchestration, reducers, or tests:

const nextCoupon = validate(form.coupon, [requiredCoupon, knownCoupon]);
const nextEmail = await validateAsync(form.email, [
  requiredEmail,
  checkEmailAvailability,
]);

setForm((prev) => ({
  ...prev,
  coupon: nextCoupon,
  email: nextEmail,
}));

validate accepts a single validator or an array and stops at the first error. validateAsync runs all validators in parallel and returns the first error in validator-list order.

Dynamic Fields

Manage dynamic field lists by storing each field's FieldState in an array:

const [items, setItems] = useState<{ id: number; state: FieldState<string> }[]>(
  [],
);

// Add a field
setItems((prev) => [...prev, { id: nextId++, state: createFieldState("") }]);

// Render
{
  items.map((item, index) => (
    <Field
      key={item.id}
      state={item.state}
      onChange={(state) =>
        setItems((prev) => {
          const next = [...prev];
          next[index].state = state;
          return next;
        })
      }
      validation={{ onChange: required }}
    >
      {(props) => (
        <input
          value={props.value}
          onChange={(e) => props.handleChange(e.target.value)}
          onBlur={props.handleBlur}
          ref={props.ref}
        />
      )}
    </Field>
  ));
}