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

@bkton/forma

v0.1.0

Published

Type-safe, schema-driven forms for React with Standard Schema support

Readme

forma

Type-safe, schema-driven forms for React. Define your shape once, attach UI with .render(), and mount the result with useSchema.

Forma combines a fluent schema builder with a reactive form store. Schemas are immutable — every .optional(), .props(), or .render() returns a new instance — so you can define reusable fields once and specialize them at each call site. Schemas also implement Standard Schema, so the same definitions work for standalone validation outside React.

Install

npm install @bkton/forma
pnpm add @bkton/forma

Peer dependency: React 18+.

Quick start

Primitives are inert until .render() attaches UI. The render callback receives reactive value and setValue — the field re-renders when the value changes.

import { object, string, useSchema } from "@bkton/forma";

const nameField = string().render(({ value, setValue }) => (
  <label>
    Name
    <input
      value={value ?? ""}
      onChange={(event) => setValue(event.target.value)}
    />
  </label>
));

const profileSchema = object({
  name: nameField,
}).render(({ content, submit }) => (
  <form
    onSubmit={(event) => {
      event.preventDefault();
      submit();
    }}
  >
    {content}
    <button type="submit">Save</button>
  </form>
));

export function ProfileForm() {
  const Form = useSchema(profileSchema.onSubmit(console.log));
  return <Form />;
}

useSchema creates a store and returns a React component. Call .onSubmit(handler) on the schema to receive the validated output when the user submits.

Core ideas

Schemas compose

| Builder | Purpose | | --- | --- | | string(), number(), boolean() | Primitive fields | | literal(value) | Fixed literal value | | object({ ... }) | Object shape | | array(element) | Homogeneous lists | | union([...]) | Value matches any option | | discriminatedUnion([...]) | Variant forms with a shared discriminator | | intersection(a, b) | Merge object fragments into a flat shape | | enumSchema([...]) / enum([...]) | Shorthand for a union of string literals |

object and array default to identity render (content only). Override with .render() to provide layout — typically a <form> shell for objects or list chrome for arrays.

Rendering is opt-in and UI-agnostic

Forma does not ship input components. You bring your own markup (plain HTML, HeroUI, Radix, etc.) inside .render() callbacks. Examples in src/examples/ use plain HTML so they depend only on React and forma.

Immutable, reusable definitions

Build a field once, then specialize it:

import { object, string, useSchema } from "@bkton/forma";

const textField = string()
  .defineProps<{ label?: string; placeholder?: string; type?: string }>({
    type: "text",
  })
  .render(({ value, setValue, props: { label, placeholder, type } }) => (
    <label>
      {label ?? "Field"}
      <input
        type={type}
        placeholder={placeholder}
        value={value ?? ""}
        onChange={(event) => setValue(event.target.value)}
      />
    </label>
  ));

const emailField = textField.email().props({
  label: "Email",
  type: "email",
  placeholder: "[email protected]",
});

const passwordField = textField.min(8).props({
  label: "Password",
  type: "password",
});

const loginSchema = object({
  email: emailField,
  password: passwordField,
}).render(({ content, submit, Validate }) => (
  <form
    onSubmit={(event) => {
      event.preventDefault();
      submit();
    }}
  >
    {content}
    <Validate>
      {(isValid) => (
        <button type="submit" disabled={!isValid}>
          Log in
        </button>
      )}
    </Validate>
  </form>
));
  • .defineProps<T>(defaults?) declares the prop type for a schema's render callback.
  • .props({ ... }) supplies prop values at a specific use site.

Selective subscriptions

Object and array shells do not re-render on every nested keystroke by default. Opt in when you need aggregate state:

  • Validate — aggregate validity for the current subtree
  • Subscribe — read a derived slice of value (e.g. array length)
  • getValue() — snapshot the tree in an event handler without subscribing

Validation

Chain built-in validators on primitives and add cross-field rules with .refine() on objects.

const passwordSchema = object({
  password: textField.min(8).props({ label: "Password" }),
  confirm: textField.min(8).props({ label: "Confirm password" }),
})
  .refine((value) => value.password === value.confirm)
  .render(({ Field, submit, Validate }) => (
    <form
      onSubmit={(event) => {
        event.preventDefault();
        submit();
      }}
    >
      <Field name="password" />
      <Field name="confirm" />
      <Validate>
        {(isValid) => (
          <button type="submit" disabled={!isValid}>
            Update password
          </button>
        )}
      </Validate>
    </form>
  ));

String validators include .min(), .max(), .email(), and .time(). Number validators include .min() and .max().

Set .validationMode("manual") to validate on demand (e.g. on blur) instead of on every change. Primitive render props include validate() and isValid for manual mode.

Standard Schema

Every schema implements StandardSchemaV1. Validate without mounting a form:

const result = passwordSchema["~standard"].validate(input);

Use InferInput and InferOutput from forma for types that align with Standard Schema inference.

Optional fields and defaults

import type { InferInput, InferOutput } from "@bkton/forma";

const settingsSchema = object({
  displayName: textField.props({ label: "Display name" }),
  bio: textField.optional().props({ label: "Bio" }),
  locale: textField.default("en").props({ label: "Locale" }),
});

type SettingsInput = InferInput<typeof settingsSchema>;
type SettingsOutput = InferOutput<typeof settingsSchema>;
  • .optional() — field may be omitted or undefined in input
  • .default(value) — seeds the form; applied during validation when input is undefined

InferInput reflects what users may submit; InferOutput is the fully resolved shape after defaults.

Arrays

Array schemas expose push, remove, and index in item render props. Use Subscribe when the list shell needs length without re-rendering on every item edit.

const todoItem = object({
  title: textField,
}).render(({ Field, index, remove }) => (
  <li>
    <Field name="title" />
    {index !== undefined && index > 0 && (
      <button type="button" onClick={remove}>
        Remove
      </button>
    )}
  </li>
));

const todoListSchema = array(todoItem).render(({ content, push, Subscribe }) => (
  <div>
    <button type="button" onClick={() => push({ title: "" })}>
      Add todo
    </button>
    <Subscribe selector={(items) => items?.length ?? 0}>
      {(length) => <p>{length} item(s)</p>}
    </Subscribe>
    <ul>{content}</ul>
  </div>
));

Discriminated unions

Switch between object variants with a shared discriminator field. .key([...]) names discriminator fields; .defaultKey([...]) picks the initial variant.

import { discriminatedUnion, literal, object, string } from "@bkton/forma";

const accountSchema = discriminatedUnion([
  object({
    type: literal("sign-in"),
    email: textField.email().props({ label: "Email" }),
    password: passwordField.props({ label: "Password" }),
  }),
  object({
    type: literal("sign-up"),
    name: textField.props({ label: "Name" }),
    email: textField.email().props({ label: "Email" }),
    password: passwordField.props({ label: "Password" }),
    confirmPassword: passwordField.props({ label: "Confirm password" }),
  }).refine((value) => value.password === value.confirmPassword),
])
  .key(["type"])
  .defaultKey(["sign-in"])
  .render(({ content, key: [type], setKey, submit, Validate }) => (
    <form onSubmit={(e) => { e.preventDefault(); submit(); }}>
      <button type="button" onClick={() => setKey(["sign-in"])}>Sign in</button>
      <button type="button" onClick={() => setKey(["sign-up"])}>Sign up</button>
      {content}
      <Validate>
        {(isValid) => <button type="submit" disabled={!isValid}>Continue</button>}
      </Validate>
    </form>
  ));

Only the active variant's fields are validated and submitted.

Intersections

Merge reusable object fragments into a flat output shape — useful for mixing field groups without extra nesting.

import { intersection, object, string } from "@bkton/forma";

const identityFields = object({
  name: textField.props({ label: "Name" }),
  email: textField.email().props({ label: "Email" }),
});

const contactFields = object({
  phone: textField.optional().props({ label: "Phone" }),
  company: textField.optional().props({ label: "Company" }),
});

// Output: { name, email, phone?, company? }
const contactFormSchema = intersection(identityFields, contactFields);

Unions and enums

union([...]) accepts any matching option. enumSchema (also exported as enum) is shorthand for a union of literals:

import { enumSchema, union, literal, object, string, number } from "@bkton/forma";

const statusField = enumSchema(["draft", "published", "archived"] as const);

const feedbackField = union([
  object({ kind: literal("note"), text: textField }),
  object({ kind: literal("score"), score: numberField }),
]);

Examples

src/examples/ contains self-contained illustrations of every pattern above:

| Example | Topic | | --- | --- | | 01-simple-form | Minimal form | | 02-reusable-fields | Props and reusable fields | | 03-validation | Built-in and custom validation | | 04-optional-and-defaults | Optional fields and defaults | | 05-arrays | Dynamic lists | | 06-discriminated-union | Variant forms | | 07-intersection | Composing object fragments | | 08-nested-reusable-blocks | Nested reusable blocks | | 09-union-and-enum | Unions and enums |

Run the playground to try them interactively:

pnpm dev:playground

Development

pnpm install
pnpm typecheck
vp pack

License

ISC