implement
Introduction

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.

We only use it to sign you in.

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)),
	);
}

Every schema library that implements Standard Schema works — valibot, zod, arktype — and formish depends on none of them. These docs use valibot.

NOTE

The API is modeled on Formisch by Fabian Hiller, which does the same job for React, Solid, Vue and Svelte. If you have used it, you already know this library; the differences are that state arrives as readables instead of framework reactivity, and the schema is any Standard Schema rather than valibot specifically.

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.

Where to go next

  • Fields — binding elements, field state, and doing it without the Field component
  • 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