A form crosses three representations: browser text and checked properties, decoded application values, and the request sent to a service. Form connects the first two with Schema codecs; the application decides the third. Read Forms as a browser contract for the full trial-request walkthrough. This guide explains the module’s mechanisms and their limits.
Bind a field codec once
Form.make(Schema.Struct(...)) returns a schema-bound factory. Its state takes decoded defaults; its Root accepts the resulting form and provides current-form context to bound child controls. NumberInput requires a numeric decoded field with a string encoding. A plain string field cannot accidentally become the name of a NumberInput.
import { Schema } from "effect";
import { component } from "@typed/template";
import * as Form from "@typed/ui/Form";
const Quantity = Form.make(Schema.Struct({
copies: Schema.FiniteFromString.pipe(Schema.check(Schema.isInt(), Schema.isGreaterThan(0))),
}));
export const QuantityField = component(function* () {
const form = yield* Quantity.state({ copies: 1 }, { id: "quantity" });
return Quantity.Root({
form,
content: [
Quantity.Label({ for: "quantity-copies", content: "Copies" }),
Quantity.NumberInput({ name: "copies", props: { id: "quantity-copies", min: 1, step: 1 } }),
Quantity.Error({ name: "copies" }),
],
});
});
The decoded default is 1; the field codec encodes it as browser text. Bound controls consume
current-form context from Root. For submission, preview, and reset together, follow the
complete form walkthrough.
Follow a value through the field boundary
Input handlers decode native strings using the field codec. A successful edit updates values and metadata, then clears that field’s error. A failed decode records an error while retaining the last decoded value. The renderer encodes that retained value back into .value; this is not a promise to preserve arbitrary incomplete draft text. Test partial numeric/date editing when selecting codecs and controls.
State contains values, defaultValues, errors, meta, and submitting. FieldMeta.dirty compares the new field value with its default using reference/value inequality; touched becomes true on a successful update, including setValue. It is not specifically a blur flag, and object/array dirty tracking is not deep equality. reset restores defaults and clears errors/meta/submitting; it does not cancel an outstanding request.
Form.setValue assigns an already-decoded value and updates metadata; it does not run the field codec or clear an existing error. Decode unknown data with Effect Schema before assigning it. Use Form.validate(form) when you need an explicit whole-form check; successful validation clears errors, while failed validation returns a SchemaError as described below.
Masked inputs preserve their editing draft
mask and slot build string codecs for structured text. When every slot declares a fixed length
and a character set distinct from the punctuation, MaskedInput adds that punctuation while typing
or pasting and preserves the caret across edits. Incomplete or invalid text remains visible until
corrected; only decoded values reach form state. This draft behavior differs from ordinary bound
inputs. Use string slot codecs for identifiers such as phone numbers so leading zeroes survive.
Variable-width or ambiguous masks still validate drafts but leave formatting to the user.
The Form API lists input variants, top-level array Push/Remove, and explicit-state controls for components that receive state directly.
Submission is not simply reading FormData
Root prevents native submission, validates the current decoded values through the schema’s Type, and invokes the handler on success. Return request work as an Effect from onValidSubmit; Root sets submitting while validation and that Effect run and clears it with finalization. A detached promise is not tracked. Native constraint validation may prevent the browser from dispatching submit first; the HTML form standard explains that platform boundary.
Current whole-form validation clears errors on success and assigns its aggregate schema error message across fields on failure. It does not build precise per-field issue paths. Because validation reads retained decoded values, a previous field decode error is not itself proof that whole-form validation will fail. Account for that distinction when a workflow must reject submission while any draft is invalid.
formDataToRecord and decodeFormData provide a separate boundary for actual FormData, preserving repeated entries as arrays. Root’s submit flow does not implicitly call them. Browser field omission and decoded-state submission are therefore different models, especially for unchecked or disabled controls.
Preserve relationships and recovery
Labels still need matching IDs. Error renders a field alert and the control receives generated aria-invalid/aria-describedby; Description is a neutral div and creates no automatic relationship. The error description is authoritative in the current input props, so do not assume a competing consumer description ID is merged. See W3C ARIA21 for identifying invalid fields.
Style actual hosts with classes and [aria-invalid], preserve focus rings, and retain error text beyond color. Bind submitting to appropriate controls, but treat duplicate request prevention and server errors as application policies. A bound input outside Root lacks CurrentForm; a nested form is not a remedy. Custom hosts must forward field props, native handlers, and hydration refs. The Form API, Button, and Checkbox provide the underlying contracts.