@bkton/forma
v0.1.0
Published
Type-safe, schema-driven forms for React with Standard Schema support
Maintainers
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/formapnpm add @bkton/formaPeer 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 subtreeSubscribe— 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 orundefinedin input.default(value)— seeds the form; applied during validation when input isundefined
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:playgroundDevelopment
pnpm install
pnpm typecheck
vp packLicense
ISC
