There is no FormBuilder here. A form is derived from a state — its field
tree, its validity and its error types are all consequences of that state and of
the mutation it submits to, so they cannot drift apart from them.
Use it when you collect input that needs validation and a typed submission.
Not when a single input maps to a single state — a plain
state with a set is enough.
::: tip Start with the guided version Learn step 8 builds a small form end to end before you dig into the individual insertions. :::
When a requirement mentions a form, start from state and add insertForm.
This map keeps the form tree, validation and mutation in one graph:
| Need | Recommended API |
|---|---|
| Form derived from state | state + insertForm |
| Nested field or object branch | insertSelectFormTree |
| Field attributes and validation | insertFormAttributes |
| Whole-form validation | insertFormSchema |
| Submit to a mutation | insertFormSubmit |
| Field errors | field.exceptions or fieldErrorNode |
| Submission state | form().submitting() |
insertNoopTypingAnchor is only a type-inference anchor for a selected field;
it adds no runtime behaviour. The common field shape is therefore:
insertSelectFormTree(
'email',
insertNoopTypingAnchor,
insertFormAttributes(() => ({ validators: [cRequired(), cEmail()] })),
);
The recommended ESLint preset enables
craft-ts/require-form-for-input-action. It catches the common interaction
where an input writes a local value and a button later reads that value to call
an action such as mutation.mutate(...) or asyncProcess.method(...). The
dependency may be nested in a record or pass through a local variable; it does
not have to be the only argument.
That interaction is a form workflow, even when it currently has only one
field:
// ❌ input state is assembled by hand and consumed by a button action
const title = yield* state('title', '');
input({ value: title, input: (event) => setTitle(eventValue(event)) });
button({ click: () => addTodo.mutate(title()) }, 'Add');
Model the value and the submit boundary together instead:
// ✅ state + insertForm + form submit
const titleForm = yield* state(
'titleForm',
'',
insertForm(
insertFormAttributes(() => ({ validators: [cRequired()] })),
insertFormSubmit(addTodo),
),
);
form('AddTodoForm', {
*submit(event) {
event.preventDefault();
yield* titleForm.form.submit();
},
}, [
input('TodoTitleInput', { type: 'text' }).pipe(
CraftFieldDirective(titleForm.form),
),
button('AddTodoButton', { type: 'submit' }, 'Add'),
]);
The rule is intentionally conservative: it does not report an input unless an
action in the same Craft template consumes the input’s bound value. It also
does not replace type checking: validation, insertFormAttributes,
CraftFieldDirective, field errors, and the exact insertFormSubmit wiring
remain the form author’s responsibility. The architecture check adds the
cross-file constraint: a mutation-backed form must use insertFormSubmit and a
native type: 'submit' button; the button must not call mutate(...) directly.
insertForm() by itself is a valid form insertion, but it is only the base
derivation. Add insertFormAttributes when the form has validators or field
rules, and add insertFormSubmit(mutation) when the form owns a mutation
submission. insertSelectFormTree is needed for fields inside an object-valued
form; it is not needed for a scalar string form.
CraftFieldDirective supports text inputs, checkboxes, selects and textareas.
Keep a stable id/htmlFor pair and put the directive on the native control:
import { input, option, select, textarea } from '@craft-ts/component';
input('animal-name', { id: 'animal-name' }).pipe(
CraftFieldDirective(animal.form.selectName()),
);
input('animal-available', { id: 'animal-available', type: 'checkbox' }).pipe(
CraftFieldDirective(animal.form.selectAvailable()),
);
select('animal-species', { id: 'animal-species' }, [
option('dog', { value: 'dog' }, 'Dog'),
option('cat', { value: 'cat' }, 'Cat'),
]).pipe(CraftFieldDirective(animal.form.selectSpecies()));
textarea('animal-notes', { id: 'animal-notes' }).pipe(
CraftFieldDirective(animal.form.selectNotes()),
);
A checkbox maps to a boolean, a select to its option value, and a textarea to a string; nested fields use the corresponding selector chain.
This is the shortest complete path: typed state, required/email validation, a mutation, server exceptions, submitting state and errors rendered next to the controls. The text in a real application should come from its i18n catalogue.
import {
button,
craftComponent,
fieldErrorNode,
form,
input,
label,
p,
} from '@craft-ts/component';
import {
cEmail,
cRequired,
CraftFieldDirective,
craftException,
insertForm,
insertFormAttributes,
insertFormSubmit,
insertNoopTypingAnchor,
insertSelectFormTree,
mutation,
state,
type ValidatedFormValue,
} from '@craft-ts/core';
type Animal = { name: string; email: string };
const saveAnimal = mutation('saveAnimal', {
method: (value: NonNullable<ValidatedFormValue<Animal>>) => value,
loader: ({ params }) =>
params.email.endsWith('@taken.test')
? craftException({ _tag: 'EMAIL_ALREADY_USED' }, { field: 'email' })
: params,
});
export const AnimalForm = craftComponent(
'AnimalForm',
{},
function* () {
const animal = yield* state(
'animalForm',
{ name: '', email: '' } satisfies Animal,
insertForm(
insertSelectFormTree(
'name',
insertNoopTypingAnchor,
insertFormAttributes(() => ({ validators: [cRequired()] })),
),
insertSelectFormTree(
'email',
insertNoopTypingAnchor,
insertFormAttributes(() => ({ validators: [cRequired(), cEmail()] })),
),
insertFormSubmit(saveAnimal),
),
);
return { animal };
},
({ animal }) =>
form(
'animal-form',
{
*submit(event) {
event.preventDefault();
yield* animal.form.submit();
},
},
[
label({ htmlFor: 'animal-name' }, 'Name'),
input('animal-name', { id: 'animal-name' })
.pipe(CraftFieldDirective(animal.form.selectName()))
.pipe(
fieldErrorNode.exhaustive({
required: () => p('Name is required.'),
}),
),
label({ htmlFor: 'animal-email' }, 'Email'),
input('animal-email', { id: 'animal-email', type: 'email' })
.pipe(CraftFieldDirective(animal.form.selectEmail()))
.pipe(
fieldErrorNode.exhaustive({
required: () => p('Email is required.'),
email: () => p('Enter a valid email.'),
}),
),
button(
'animal-submit',
{ type: 'submit', disabled: animal.form.submitting },
'Save',
),
p(function* () {
if (!(yield* animal.form.hasSubmitExceptions())) return '';
return 'The server rejected this animal.';
}),
],
),
);
The advanced version uses the same primitives for nested address fields,
conditional visibility and asynchronous validation. Keep the branch insertion
in the feature rather than hiding it in a component library; see
Nested forms and
Validation.
Three pillars, all of which follow from deriving rather than declaring:
All of this is possible because the logic is entirely derived from the state.
Form insertions enable modular composition of functionality:
The primary insertion that derives a typed form from a primitive.
import { craftUse, state } from '@craft-ts/core';
import {
insertForm,
insertFormAttributes,
insertNoopTypingAnchor,
insertSelectFormTree,
cRequired,
cEmail,
} from '@craft-ts/core';
const userFormState = craftUse(
state(
'userFormState',
{ name: '', email: '' },
insertForm(
insertSelectFormTree(
'name',
insertNoopTypingAnchor, // TS limitation
insertFormAttributes(() => ({
validators: [cRequired()],
})),
),
insertSelectFormTree(
'email',
insertNoopTypingAnchor, // TS limitation
insertFormAttributes(() => ({
validators: [cRequired(), cEmail()],
})),
),
),
),
);
const form = userFormState.form;
const nameField = form.selectName();
const emailField = form.selectEmail();
Note: It only works with the
stateprimitive from now.
insertNoopTypingAnchoris a special insertion that does not add any logic but allows to anchor the typing of the form field. It is required for the form system to infer the correct types of fields and exceptions. (TS limitations…)
Adds attributes and validators to a form field.
const formState = craftUse(
state(
'formState',
{ email: '' },
insertForm(
insertSelectFormTree(
'email',
insertNoopTypingAnchor,
insertFormAttributes(() => ({
validators: [cRequired(), cEmail()],
disable: () => isLoading(),
hidden: () => !showField(),
})),
),
),
),
);
// Access email field and its exceptions
const form = formState.form;
const emailField = form.selectEmail();
const errors = emailField()().exceptions.list; // fully typed list of exceptions
const emailError = emailField()().exceptions.byValidator['cEmail'];
CraftFieldDirective is the DOM adapter for a CraftField. It binds the field
in both directions, marks it touched on blur, and reflects field state through
native attributes and craft-* CSS classes.
In a Craft template, apply the functional directive to the concrete node:
import { CraftFieldDirective } from '@craft-ts/core';
input({
type: 'email',
}).pipe(CraftFieldDirective(loginForm.form.selectEmail()));
insertSelectFormTree materializes its branch lazily. When validators or other
insertions are attached through it, bind the field returned by selectEmail()
(or the corresponding selectXxx() method). Binding the raw
loginForm.form.email field bypasses that materialization, so those insertions
are not registered.
The directive supports text inputs and textareas, numeric and temporal inputs,
checkboxes, radio groups and selects. Validators also project native constraints
such as required, min, max, minlength and maxlength.
For a custom control, provide CRAFT_FIELD_VALUE_CONTROL or
CRAFT_FIELD_CHECKBOX_CONTROL on the component root. Native Craft nodes use the
functional directive directly.
fieldErrorNode.exhaustive turns validation cases carried by
CraftFieldDirective or exposed by the component logic into compile-time UI
obligations. Every reachable code must have one handler, and an unreachable
handler is also rejected.
import { fieldErrorNode, input, p } from '@craft-ts/component';
input({ id: 'email', type: 'email' })
.pipe(CraftFieldDirective(loginForm.form.selectEmail()))
.pipe(
fieldErrorNode.exhaustive({
required: () => p('Email is required.'),
email: () => p('Enter a valid email.'),
}),
);
The field stays mounted and invalid while a message is visible. The block adds
and merges aria-invalid and aria-describedby; it does not throw an
exception or feed route handleExceptions.
Use fieldErrorNode.partial when only some codes belong near the field.
Handled codes are removed from its contract and the remaining codes continue
to the next field-exception boundary:
input({ id: 'password', type: 'password' })
.pipe(CraftFieldDirective(loginForm.form.selectPassword()))
.pipe(
fieldErrorNode.partial({
required: () => p('Password is required.'),
}),
);
Here password.required is handled locally, while password.minLength must
still be handled by an enclosing partial or exhaustive block. A partial
block may omit reachable codes, but an unreachable handler remains a TypeScript
error.
At a component boundary, group handlers by static field path. Identical codes on different fields remain separate obligations:
const SafeLoginForm = BaseLoginForm.pipe(
fieldErrorNode.exhaustive({
email: {
required: () => p('Email is required.'),
email: () => p('Enter a valid email.'),
},
password: {
required: () => p('Password is required.'),
minLength: ({ exception }) =>
p(`Use at least ${exception.payload} characters.`),
},
}),
);
Object branches may also carry group or cross-field validators. Materialize the branch in the component logic and return it from the factory:
const credentials = registration.form.selectCredentials();
return { registration, credentials };
Its cases, for example credentials.passwordMismatch, are part of the
component contract even when the group itself is not passed to
CraftFieldDirective. Handle the grouped path on an enclosing template VNode
or with BaseComponent.pipe(fieldErrorNode.exhaustive(...)). If it remains
unhandled, rendering, mounting, and loadCraftComponent reject the component
at compile time. See Form exception handling for the
complete group example.
By default the block reads the field’s visibleExceptions directly. The form
owns that visibility policy; the default is touched or submitted:
insertFormAttributes(() => ({
validators: [cRequired(), cEmail()],
exceptionVisibility: { anyOf: ['touched', 'submitted'] },
}));
After a blur, only that field’s visible exceptions are rendered. A submit
attempt reveals the remaining exceptions for every field. Available states are
dirty, touched, and submitted; a block can override
the inherited policy with visibility: 'always', another anyOf combination,
or a predicate. mode is first (validator order) or all, and position is
before or after. Resetting the form clears dirty, touched, and submitted,
so inherited messages are hidden again.
Custom and async validators participate through their declared exception union exactly like built-ins: their codes must be handled even when the current visibility policy hides them.
Adds a form-level StandardSchemaV1 validator. Issues are projected onto the
matching fields by their schema path, while root and unmaterialized issues stay
available through schemaExceptions().
const formState = craftUse(
state(
'formState',
{ email: '' },
insertForm(insertFormSchema(userSchema), insertFormSubmit(saveUser)),
),
);
const form = formState.form;
form.email.errors();
form.hasSchemaExceptions();
form.schemaExceptions();
The form keeps the schema input value. Schema transformations belong at the
submit boundary, for example through the mutation’s methodSchema.
insertFormSubmit connects the form to a mutation. It submits only validated
form values and exposes the mutation’s loading and typed exception state on the
form.
See Submitting a form for the complete submission workflow, including success handling and exception transformations.