Introduction
Schema-first forms for implement — typed fields, validation, and field arrays.
@implementjs/formish manages what a form holds and what is wrong with it. You write a schema, the schema types the fields, validates the input and produces the value your submit handler receives. Formish owns none of the markup: you render the inputs, it hands you the state to bind.
import { If, signal } from "@implementjs/core";
import { createForm, Form, useField } from "@implementjs/formish";
import * as v from "valibot";
import { Button } from "@/lib/components/ui/button";
import {
Field,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
} from "@/lib/components/ui/field";
import { Input } from "@/lib/components/ui/input";
const SignUpSchema = v.object({
email: v.pipe(
v.string(),
v.minLength(1, "Enter your email"),
v.email("That does not look like an email"),
),
password: v.pipe(v.string(), v.minLength(8, "At least 8 characters")),
});
type SignUpForm = ReturnType<typeof createForm<typeof SignUpSchema>>;
export default function SignUpFormDemo() {
const form = createForm({ schema: SignUpSchema, validate: "blur" });
const signedUpAs = signal("");
return Form(
{
of: form,
onSubmit: (output) => signedUpAs.set(output.email),
class: "w-full max-w-sm",
},
FieldGroup(
TextField(form, {
path: "email",
label: "Email",
type: "email",
description: "We only use it to sign you in.",
}),
TextField(form, { path: "password", label: "Password", type: "password" }),
Button({ type: "submit", disabled: form.isSubmitting }, "Sign up"),
If(signedUpAs).Then(FieldDescription(signedUpAs.bind((email) => `Signed up as ${email}`))),
),
);
}
/**
* One field of the form. The state comes from formish, the look from the ui
* `Field` components: `data-invalid` on the field turns the label and the
* message destructive, `aria-invalid` on the control is what gets announced.
*/
function TextField(
form: SignUpForm,
{
path,
label,
type,
description,
}: {
path: "email" | "password";
label: string;
type: "email" | "password";
description?: string;
},
) {
const field = useField(form, { path: [path] });
const id = `signup-${path}`;
// set only while the field has an error: `data-invalid` styles the field,
// `aria-invalid` announces it
const invalid = field.errors.bind((errors) => (errors ? "true" : undefined));
return Field(
{ "data-invalid": invalid },
FieldLabel({ for: id }, label),
Input({ ...field.props, id, type, value: field.input, "aria-invalid": invalid }),
...(description ? [FieldDescription(description)] : []),
If(field.error).Then(FieldError(field.error)),
);
}Schemas are valibot. Formish reads the schema itself, not just the values it rejects: at createForm it walks the schema and gives every field a starting value, which is what lets a field validate whether or not you ever render it.
NOTE
This is a port of Formisch by Fabian Hiller, which does the same job for React, Solid, Vue, Svelte and friends. Same schemas, same store shapes, same methods, same semantics — the difference is that state arrives as readables instead of framework reactivity. What differs lists everything that is not the same.
Every field starts at a value
A form's fields are whatever the schema says they are. Formish builds a field store for each of them when the form is created, and gives it a starting value — "" for a string, [] for an array, null for a nullable — so input holds the whole shape before anything is rendered:
const form = createForm({
schema: v.object({
email: v.pipe(v.string(), v.email("Enter a valid email")),
nickname: v.string(),
}),
});
getInput(form); // { email: "", nickname: "" }
The practical effect is that forgetting to render a field does not quietly break the form. nickname above has nothing left to satisfy, so the form still submits, with nickname: "". If it were v.pipe(v.string(), v.minLength(1, "Pick a nickname")), submission would be blocked — but the error is the schema's own message on ["nickname"], which getDeepErrorEntries will show you, rather than a type error about a value nothing on screen was ever going to supply.
Fields that accept nothing are left that way: an optional field stays missing, and a nullable one starts at null. Only a string starts empty by default — a required number or boolean has no value the form may invent, so it stays missing and the schema says so. Where a required field starts is configurable per type with emptyInput.
Installation
npm install @implementjs/formish valibot
A new app can start with it already wired up:
npm create implement-app@latest my-app -- --forms
A form in three pieces
import { Button, Div, Input, Label, Span } from "@implementjs/core";
import { createForm, Field, Form } from "@implementjs/formish";
import * as v from "valibot";
const SignUpSchema = v.object({
email: v.pipe(v.string(), v.email("Enter a valid email")),
password: v.pipe(v.string(), v.minLength(8, "At least 8 characters")),
});
export function SignUp() {
const form = createForm({ schema: SignUpSchema });
return Form(
{ of: form, onSubmit: (output) => api.signUp(output) },
Field({ of: form, path: ["email"] }, (field) =>
Div(
Label({ for: "email" }, "Email"),
Input({ ...field.props, id: "email", type: "email", value: field.input }),
Span(field.error),
),
),
Button({ type: "submit", disabled: form.isSubmitting }, "Sign up"),
);
}
createForm builds the store. It holds the input, the errors, and whether the form is submitting, submitted, touched, edited, dirty or valid — all as readables.
Form renders the <form> element. It turns off the browser's own validation, validates on submit, focuses the first field with an error, and only then calls onSubmit — with the schema's output, so a transform in the schema has already run.
Field looks a field up by path and renders it. field.props carries the name and the event handlers; the value binding stays yours, because only you know whether this element wants value or checked.
The markup above is plain elements, so it works in any app. The demos on these pages use the Field components from @implementjs/ui instead — same state, styled — which Fields shows how to wire up.
Why paths
A field is addressed by a path — ["email"], ["todos", 0, "label"] — rather than a string name. The path is checked against the schema, so a typo is a type error and autocompletion knows what comes next:
useField(form, { path: ["profile", "nickname"] }); // Readable<string | undefined>
useField(form, { path: ["profile", "nickmame"] }); // type error
The same path is what the store keys state on and what the element's name attribute becomes (profile.nickname), which is how a radio or checkbox group ties its elements together.
What differs from Formisch
Everything Formisch exports, formish exports, with the same configs and the same behaviour. Four things are not the same:
- State is a readable.
field.inputis aReadable<T>rather than a value a re-render hands you, andformcarries aninputreadable of its own where Formisch has onlygetInput(form).field.error,field.nameanduseFieldArray'sitemPathand bound array methods are additions on top; nothing upstream has is missing. - A path may be a readable. Formisch rebuilds
["todos", index, "label"]on every render; implement has nothing to re-render, so a path that has to follow an array item as it moves arrives as a readable instead. Everypath— onuseField, on the components, on the methods — takes either. nameis dotted.todos.0.label, not the JSON Formisch writes. Anamecontaining quotes is legal HTML but cannot be addressed by a CSS selector, which is whatdocument.getElementsByNameand a radio group's own grouping are built on.- A
nullablefield starts atnull, and anexactOptionalfield keeps its key absent. Formisch leaves bothundefined, whichv.nullableandv.exactOptionaleach reject — so the two schemas could never validate. Everything else about where fields start is upstream's, includingemptyInputdefaulting to{ string: "" }alone.
Where to go next
- Fields — binding elements, field state, and doing it without the
Fieldcomponent - Validation — when a form validates, async schemas, and errors from a server
- Field arrays — lists that can be added to, reordered and removed from
- Special inputs — checkboxes, radios, selects, files, numbers and dates
- API — every export, in one table