{"protocol":"clank-doc/1","frameworkVersion":"0.8.0","slug":"forms","title":"Forms","description":"Clank forms are headless: the framework owns state, validation, accessible control wiring, submission, and cancellation while the application owns its HTML and Tailwind classes.","group":{"id":"framework","title":"Framework"},"url":"https://docs.clank.run/docs/forms","source":"docs/forms.md","headings":["Create a form","Render native controls","State","Validation","Submission and server errors","Form manifests for agents","Multi step and nested forms","Security notes"],"tableOfContents":[{"id":"create-a-form","title":"Create a form","level":2},{"id":"render-native-controls","title":"Render native controls","level":2},{"id":"state","title":"State","level":2},{"id":"validation","title":"Validation","level":2},{"id":"submission-and-server-errors","title":"Submission and server errors","level":2},{"id":"form-manifests-for-agents","title":"Form manifests for agents","level":2},{"id":"multi-step-and-nested-forms","title":"Multi-step and nested forms","level":2},{"id":"security-notes","title":"Security notes","level":2}],"markdown":"# Forms\n\nClank forms are headless: the framework owns state, validation, accessible control wiring, submission, and cancellation while the application owns its HTML and Tailwind classes.\n\nThis keeps generated code readable. There is no component DSL to learn and no required visual style.\n\n## Create a form\n\n```tsx\nimport { createForm, s } from \"@clank.run/framework\";\n\nconst signup = createForm({\n  id: \"signup\",\n  initial: {\n    name: \"\",\n    email: \"\",\n    plan: \"starter\" as \"starter\" | \"team\",\n    accepted: false,\n  },\n  schema: s.object({\n    name: s.string({ min: 2, max: 80 }),\n    email: s.email({ max: 160 }),\n    plan: s.enum([\"starter\", \"team\"]),\n    accepted: s.literal(true),\n  }),\n  validateOn: \"blur\",\n  onSubmit: async (values, { signal }) => {\n    await saveAccount(values, { signal });\n  },\n});\n```\n\nThe initial object is the inference root. Field names, values, `setValue`, validation output, and `onSubmit` values retain those types.\n\nThe `id` is deterministic so SSR and hydration produce the same control, error, and description IDs.\n\n## Render native controls\n\n```tsx\nfunction SignupForm() {\n  const name = signup.field(\"name\");\n  const email = signup.field(\"email\");\n  const plan = signup.field(\"plan\");\n  const accepted = signup.field(\"accepted\");\n\n  return (\n    <form {...signup.props()} class=\"space-y-5\">\n      <div>\n        <label for={name.id}>Name</label>\n        <input {...name.input()} autocomplete=\"name\" />\n        <p {...name.error()}>{name.message.value}</p>\n      </div>\n\n      <div>\n        <label for={email.id}>Email</label>\n        <input {...email.input({ type: \"email\" })} autocomplete=\"email\" />\n        <p {...email.error()}>{email.message.value}</p>\n      </div>\n\n      <select {...plan.select()}>\n        <option value=\"starter\">Starter</option>\n        <option value=\"team\">Team</option>\n      </select>\n\n      <label>\n        <input {...accepted.checkbox()} />\n        I agree to the terms.\n      </label>\n\n      <button type=\"submit\" disabled={signup.pending.value}>\n        {signup.pending.value ? \"Creating…\" : \"Create account\"}\n      </button>\n    </form>\n  );\n}\n```\n\nField helpers return ordinary Clank/HTML props:\n\n- `input()` handles text, date, email, password, number, range, and other native inputs.\n- `textarea()` handles multiline text.\n- `select()` handles single or multiple selection.\n- `checkbox()` is available only on boolean fields in TypeScript.\n- `radio({ value })` creates one option for a field.\n- `error()` provides a stable ID, polite live region, and reactive `hidden` state.\n\n`aria-invalid` remains explicitly `\"true\"` or `\"false\"`. When errors exist, the control automatically references its error element with `aria-describedby`.\n\n## State\n\nThe form controller exposes:\n\n```ts\nsignup.values.value;\nsignup.dirty.value;\nsignup.valid.value;\nsignup.pending.value;\nsignup.submitted.value;\nsignup.submitCount.value;\nsignup.status.value; // idle | invalid | submitting | success | error\nsignup.result.value;\nsignup.error.value;\nsignup.formErrors.value;\n```\n\nEach field exposes `value`, `errors`, `message`, `touched`, `dirty`, and `invalid`.\n\nImperative updates remain explicit:\n\n```ts\nsignup.setValue(\"plan\", \"team\");\nsignup.setValues({ name: \"Ada\", email: \"ada@example.com\" });\nsignup.reset();\nsignup.reset({\n  name: \"Grace\",\n  email: \"grace@example.com\",\n  plan: \"team\",\n  accepted: true,\n});\n```\n\nReset values must contain exactly the original fields. Unknown field errors throw instead of disappearing silently.\n\n## Validation\n\nSchema validation is synchronous and deterministic:\n\n```ts\nsignup.validate(\"manual\");\n```\n\nValidation issues are mapped by their first path segment. Nested issues remain attached to their top-level form field, which works well when nested editors are composed as separate form controllers.\n\nCross-field validation is a plain function:\n\n```ts\nconst stay = createForm({\n  id: \"stay\",\n  initial: { checkIn: \"\", checkOut: \"\" },\n  schema: s.object({\n    checkIn: s.date(),\n    checkOut: s.date(),\n  }),\n  validate(values) {\n    return values.checkOut <= values.checkIn\n      ? { checkOut: \"Check-out must be after check-in.\" }\n      : undefined;\n  },\n});\n```\n\n`validateOn` may be `submit`, `blur`, or `input`. Expensive remote checks belong in `onSubmit`; the server can return field errors through `setErrors`.\n\n## Submission and server errors\n\n```ts\nconst invite = createForm({\n  id: \"invite\",\n  initial: { email: \"\" },\n  schema: s.object({ email: s.email() }),\n  onSubmit: async (values, { signal, setErrors }) => {\n    const response = await fetch(\"/api/invitations\", {\n      method: \"POST\",\n      signal,\n      body: JSON.stringify(values),\n    });\n    if (response.status === 409) {\n      setErrors({ email: \"That person is already invited.\" });\n      return;\n    }\n    if (!response.ok) throw new Error(\"Invitation failed.\");\n  },\n});\n```\n\nNew submissions abort older submissions by default and ignore their stale results. Set `concurrency: \"ignore\"` when a second submit should do nothing while the first is pending.\n\nAn invalid submit marks every field touched and focuses the first invalid named control unless `focusFirstError: false` is configured.\n\n`resetOnSuccess: true` restores initial values after a successful submit. A manual `reset(values)` establishes a new baseline.\n\n## Form manifests for agents\n\nEvery controller exposes a stable manifest:\n\n```ts\nsignup.manifest;\n```\n\nThe protocol is `clank-form/1`. It includes the form ID, JSON Schema, field names, required state, and a suggested native control type.\n\nThe manifest does not contain live field values. Rendered controls remain discoverable through native IDs, associated labels, semantic roles, and optional `agentId`/`agentAction` metadata.\n\nPassword and file-input values are deliberately omitted from semantic inspection.\n\n## Multi-step and nested forms\n\nCompose one controller per independently validated step:\n\n```ts\nconst dates = createForm({ /* … */ });\nconst room = signal(\"standard\");\nconst guest = createForm({ /* … */ });\n```\n\nThis produces smaller contracts, clearer error ownership, and simpler generated code than a single controller with conditional nested paths. The booking example under `examples/booking` demonstrates the pattern.\n\n## Security notes\n\n- Client validation improves UX; server actions must validate again.\n- Never render `form.error.value` directly when it may contain a sensitive server exception. Map it to a user-safe message.\n- Do not place secrets in initial values, labels, agent metadata, or form manifests.\n- File uploads require an explicit upload transport. `createAgentSurface().input()` refuses file inputs.\n- Form cancellation is cooperative. Pass the provided `AbortSignal` into Fetch or other cancellable work.\n"}