Ilha Form

@ilha/form is a tiny, typed form binding library for ilha islands. It connects a Standard Schema validator to a native <form> and gives you typed submit handling, per-field errors, dirty state, default values, and DOM-based validation — no runtime dependencies beyond your chosen schema library.

How it relates to ilha

@ilha/form is not a form component — it is a lifecycle binding. createForm() attaches to a real HTMLFormElement in your island's rendered HTML. You call form.mount() inside .effect() and return the cleanup, giving the form the same reactive lifetime as the island it lives in.

Install

npm
yarn
pnpm
bun
deno
npm install @ilha/form

Quick start

import { z } from "zod";
import ilha, { html } from "ilha";
import { createForm, issuesToErrors, type FormErrors } from "@ilha/form";

const ContactForm = ilha
  .state("errors", {} as FormErrors)
  .effect(({ host, state }) => {
    const form = createForm({
      el: host.querySelector("form")!,
      schema: z.object({
        email: z.string().email("Invalid email"),
        name: z.string().min(1, "Name is required"),
      }),
      onSubmit(values) {
        console.log(values); // fully typed
      },
      onError(issues) {
        state.errors(issuesToErrors(issues));
      },
      validateOn: "change",
    });

    return form.mount();
  })
  .render(
    ({ state }) => html`
      <form>
        <input name="email" type="email" />
        <input name="name" />
        ${state.errors().email?.length ? html`<p>${state.errors().email}</p>` : ""}
        ${state.errors().name?.length ? html`<p>${state.errors().name}</p>` : ""}
        <button type="submit">Submit</button>
      </form>
    `,
  );

createForm() creates the binding, and form.mount() attaches the listeners and returns a cleanup function. This fits naturally inside an ilha .effect() lifecycle.


API

createForm(options)

Creates a form binding instance for a native HTMLFormElement. It does not attach listeners until mount() is called, and it uses native DOM events only.

const form = createForm({
  el: document.querySelector("form")!,
  schema: mySchema,
  onSubmit(values, event) {
    // values are fully typed from the schema output
  },
  onError(issues, event) {
    // validation issues from Standard Schema
  },
  validateOn: "submit",
  defaultValues: {
    email: "ada@example.com",
  },
});

Options

OptionTypeRequiredDescription
elHTMLFormElementYesThe form element to bind.
schemaStandardSchemaV1YesAny Standard Schema compatible validator such as Zod, Valibot, or ArkType.
onSubmit(values, event) => voidYesCalled with typed schema output after a valid submit.
onError(issues, event) => voidNoCalled when submit validation fails.
validateOn"submit" | "change" | "input"NoControls automatic validation; defaults to "submit".
defaultValuesRecord<string, string | string[]>NoInitial DOM values applied on mount() and tracked across re-renders.

Form methods

form.mount()

Attaches listeners, applies defaultValues, resets dirty and error state, and returns a cleanup function equivalent to form.unmount(). Calling the returned cleanup function prevents further submit handling.

form.unmount()

Removes listeners and disconnects the MutationObserver used for re-applying tracked values after re-renders. It is idempotent and safe to call multiple times.

form.values()

Reads the current form values with FormData, validates them synchronously, and returns a discriminated union. It never throws validation failures, and it works even before mount().

const result = form.values();

if (result.ok) {
  console.log(result.data);
} else {
  console.log(result.issues);
}

form.errors()

Returns a copy of the per-field error map from the most recent validation run. It is empty before the first validation, and mutating the returned object does not affect internal state.

form.isDirty()

Returns true after field interaction marks the form dirty, and resets to false on a new mount(). Programmatic setValue() does not mark the form dirty.

form.submit()

Triggers the same validation and submit cycle programmatically. When mounted, it dispatches a real SubmitEvent; otherwise it validates directly and calls the same handlers.

form.setValue(name, value)

Programmatically applies a value to matching DOM fields. Supports text inputs, checkboxes, radios, selects, multi-selects, and textareas. Skips file inputs, and works even when the form is not mounted.


Validation behavior

validateOn controls when field-level validation runs automatically. The "submit" mode is more nuanced than "submit only": while the form has no active errors it stays quiet, but after a failed submit it revalidates on later change and input events until the errors are cleared.

ModeBehavior
submitValidate on submit while clean; after a failed submit, revalidate on change and input until all errors clear.
changeValidate on every change; also validates on input while errors are active.
inputValidate on every input event.

This keeps the form quiet before the user tries to submit, then more responsive while they fix invalid fields.


Default values

defaultValues are applied on mount(), not at createForm() time. They are tracked and re-applied after DOM re-renders with a MutationObserver, while preserving user-edited values for tracked fields when possible.

const form = createForm({
  el: document.querySelector("form")!,
  schema: z.object({
    email: z.string().email(),
    role: z.string(),
    tags: z.array(z.string()),
  }),
  defaultValues: {
    email: "ada@example.com",
    role: "admin",
    tags: ["a", "c"],
  },
  onSubmit(values) {
    console.log(values);
  },
});

form.mount();

defaultValues support plain inputs, checkbox groups, radios, select, select[multiple], and textarea. File inputs are skipped.


Helpers

issuesToErrors(issues)

Converts Standard Schema issues into a field-error object keyed by dot-separated paths like user.email. Each value is an array of messages, so multiple messages for the same field are preserved.

import { issuesToErrors } from "@ilha/form";

Notes

  • Validation is synchronous only — async schema validation is not supported, and async validators return a failed result instead of throwing.
  • The library relies on native FormData, SubmitEvent, MutationObserver, and DOM events.
  • form.errors() and form.values() are methods, not properties.
  • issuesToErrors should be imported explicitly when used.

TypeScript

All types are inferred from the schema output. Key exported types:

import type {
  FormErrors, // Record<string, string[]> — per-field error map
  FormInstance, // the object returned by createForm()
} from "@ilha/form";

Example

import { z } from "zod";
import { createForm, issuesToErrors } from "@ilha/form";

const form = createForm({
  el: document.querySelector("form")!,
  schema: z.object({
    email: z.string().email("Invalid email"),
    name: z.string().min(1, "Name is required"),
  }),
  onSubmit(values) {
    console.log(values.email);
  },
  onError(issues) {
    console.log(issuesToErrors(issues));
  },
  validateOn: "input",
});

const unmount = form.mount();