Domphy

Form Composition

formOptions() — shared form configuration

formOptions() (from @domphy/form) creates a typed options object that can be spread into multiple createForm() calls. Useful for shared validators, default values, or submit handlers:

import { formOptions } from "@domphy/form"
import { createForm } from "@domphy/form/domphy"

const baseContactOptions = formOptions<{ email: string; phone: string }>({
  defaultValues: { email: "", phone: "" },
  validators: {
    onSubmit: ({ value }) => {
      if (!value.email && !value.phone) return "Provide an email or phone number"
    },
  },
})

// Use the shared options in a checkout form
const checkoutForm = createForm({
  ...baseContactOptions,
  onSubmit: async ({ value }) => completeCheckout(value),
})

// And in a profile form — override the onSubmit, keep the validators
const profileForm = createForm({
  ...baseContactOptions,
  defaultValues: { email: user.email, phone: user.phone },
  onSubmit: async ({ value }) => updateProfile(value),
})

formOptions() preserves TypeScript inference — the returned object is typed exactly as passed, so spreading it into createForm() keeps full type safety.

Reusable form section factories

Create a function that returns a set of fields bound to a specific form, then call it in multiple places:

import { createForm } from "@domphy/form/domphy"
import { inputText, label, formGroup } from "@domphy/ui"
import { themeSpacing } from "@domphy/theme"

interface AddressInput {
  street: string
  city: string
  zip: string
}

// Factory: returns bound field handles for an address section
function createAddressSection(
  form: ReturnType<typeof createForm<{ address: AddressInput }>>,
  prefix: string = "address",
) {
  const street = form.field<string>(`${prefix}.street`, {
    validators: { onChange: ({ value }) => value ? undefined : "Required" },
  })
  const city = form.field<string>(`${prefix}.city`, {
    validators: { onChange: ({ value }) => value ? undefined : "Required" },
  })
  const zip = form.field<string>(`${prefix}.zip`, {
    validators: { onChange: ({ value }) => /^\d{5}$/.test(value) ? undefined : "5 digits" },
  })

  function AddressView() {
    return {
      fieldset: [
        { legend: "Address" },
        { label: "Street", $: [label()] },
        {
          input: null,
          type: "text",
          $: [inputText()],
          value: (l) => street.value(l),
          onInput: (e) => street.handleChange((e.target as HTMLInputElement).value),
          onBlur: () => street.handleBlur(),
        },
        {
          p: (l) => String(street.errors(l)[0] ?? ""),
          hidden: (l) => street.errors(l).length === 0,
        },
        { label: "City", $: [label()] },
        {
          input: null,
          type: "text",
          $: [inputText()],
          value: (l) => city.value(l),
          onInput: (e) => city.handleChange((e.target as HTMLInputElement).value),
          onBlur: () => city.handleBlur(),
        },
        {
          p: (l) => String(city.errors(l)[0] ?? ""),
          hidden: (l) => city.errors(l).length === 0,
        },
        { label: "ZIP", $: [label()] },
        {
          input: null,
          type: "text",
          $: [inputText()],
          value: (l) => zip.value(l),
          onInput: (e) => zip.handleChange((e.target as HTMLInputElement).value),
          onBlur: () => zip.handleBlur(),
        },
        {
          p: (l) => String(zip.errors(l)[0] ?? ""),
          hidden: (l) => zip.errors(l).length === 0,
        },
      ],
      $: [formGroup({ layout: "vertical" })],
    }
  }

  return { street, city, zip, AddressView }
}

// Usage in a checkout form
const checkoutForm = createForm<{ address: AddressInput }>({
  defaultValues: { address: { street: "", city: "", zip: "" } },
  onSubmit: ({ value }) => checkout(value),
})

const address = createAddressSection(checkoutForm)

const CheckoutPage = {
  form: [
    address.AddressView(),
    {
      button: "Place order",
      type: "submit",
    },
  ],
  onSubmit: (e) => { (e as Event).preventDefault(); checkoutForm.handleSubmit() },
  _onRemove: () => checkoutForm.destroy(),
}

Module-level form state

Since createForm() returns a plain object (not a hook), you can create a form at module scope and share it across files:

// forms/signup.ts — created once, shared across modules
import { createForm } from "@domphy/form/domphy"

export interface SignupInput {
  email: string
  name: string
  plan: "free" | "pro"
}

export const signupForm = createForm<SignupInput>({
  formId: "signup",
  defaultValues: { email: "", name: "", plan: "free" },
  onSubmit: async ({ value }) => registerUser(value),
})

// Bind fields once — re-use anywhere
export const emailField = signupForm.field<string>("email", {
  validators: { onChange: ({ value }) => value.includes("@") ? undefined : "Invalid email" },
})
export const nameField = signupForm.field<string>("name", {
  validators: { onChange: ({ value }) => value ? undefined : "Required" },
})
export const planField = signupForm.field<"free" | "pro">("plan", {})
// pages/step1.ts — imports the shared form
import { signupForm, emailField, nameField } from "../forms/signup"

const Step1 = {
  form: [
    { /* email input bound to emailField */ },
    { /* name input bound to nameField */ },
    {
      button: "Next",
      type: "button",
      onClick: () => signupForm.form.validateAllFields("submit").then(() => {
        if (signupForm.form.state.isValid) goToStep2()
      }),
    },
  ],
  onSubmit: (e) => (e as Event).preventDefault(),
}

mergeForm() — pre-populate from server state

mergeForm() (from @domphy/form) deep-merges a partial FormState into an existing FormApi. Used primarily for SSR: the server serializes form state to JSON, sends it to the client, and the client merges it in before first render.

See SSR & Hydration for the full pattern.

import { mergeForm } from "@domphy/form"
import { createForm } from "@domphy/form/domphy"

const form = createForm<{ title: string; body: string }>({
  defaultValues: { title: "", body: "" },
  onSubmit: ({ value }) => save(value),
})

// Apply server-side state (e.g. pre-populated values, server validation errors)
const serverState = await fetchFormState()   // { values: {...}, fieldMeta: {...} }
mergeForm(form.form, serverState)

mergeForm() mutates the form state directly — call it before mounting the form UI.

Sharing validators between fields

Extract common validators into plain functions:

const required = ({ value }: { value: string }) =>
  value.trim() ? undefined : "This field is required"

const validEmail = ({ value }: { value: string }) =>
  /^[^@]+@[^@]+\.[^@]+$/.test(value) ? undefined : "Invalid email address"

const minLength = (n: number) => ({ value }: { value: string }) =>
  value.length >= n ? undefined : `Minimum ${n} characters`

// Usage
const emailField = form.field<string>("email", {
  validators: { onChange: validEmail, onBlur: validEmail },
})

const nameField = form.field<string>("name", {
  validators: { onChange: required, onBlur: minLength(2) },
})

const passwordField = form.field<string>("password", {
  validators: { onChange: minLength(8) },
})

Using Standard Schema for reusable validation

A Zod schema defined once can be shared across multiple forms:

import { z } from "zod"

export const emailSchema = z.string().email("Invalid email")
export const passwordSchema = z.string().min(8, "At least 8 characters")

// Apply to any form that has an email field
const loginForm = createForm<{ email: string; password: string }>({
  defaultValues: { email: "", password: "" },
  onSubmit: ({ value }) => login(value),
})

const loginEmail = loginForm.field("email", {
  validators: { onChange: emailSchema },
})
const loginPassword = loginForm.field("password", {
  validators: { onChange: passwordSchema },
})

// Same schemas in a signup form
const signupForm = createForm<{ email: string; password: string; name: string }>({
  defaultValues: { email: "", password: "", name: "" },
  onSubmit: ({ value }) => signup(value),
})

const signupEmail = signupForm.field("email", {
  validators: { onChange: emailSchema },
})
const signupPassword = signupForm.field("password", {
  validators: { onChange: passwordSchema },
})