Forms
useFormFor manages state, validation, submission, and per-field errors. It renders no
components: the page chooses the fields and their layout.
<script setup lang="ts">
import Post from '@fronds/blog/entities/Post.js';
const { fieldsByName, values, errors, submit, loading, error } = useFormFor(Post);
async function onSubmit() {
const created = await submit(); // null on validation error
if (created) navigateTo(`/blog/edit/${created.id}`);
}
</script>
<template>
<form novalidate @submit.prevent="onSubmit">
<UFormField label="Title" :error="errors.title">
<UInput v-model="values.title" v-bind="fieldsByName.title?.attrs" />
</UFormField>
<p v-if="error">{{ error.message }}</p> <!-- non-validation failure -->
<UButton type="submit" :loading="loading" />
</form>
</template>
Signature
useFormFor(EntityOrView, options?)
// options: {
// op?: string command the submit rides — default 'create'
// to?: facade where it submits — default the one named like the entity
// initial?: Record<string, unknown> edit mode: the loaded entity
// params?: Record<string, string> designation of the target — edit mode: { id }
// }
Returns:
| Key | Type | Notes |
|---|---|---|
fields | FormField[] | derived from the io axes — readOnly fields are excluded |
fieldsByName | Record<string, FormField> | the same fields, for a form laid out by hand |
values | reactive row, every field optional | initial when editing, the field's declared default when creating |
errors | reactive record | field name → message, keyed by the entity's fields |
submit | () => Promise<Row | null> | see cycle below |
loading | Ref<boolean> | |
error | Ref<FougereError | null> | non-validation failure (conflict, dead host) |
valid | ComputedRef<boolean> | no current field errors |
choices | reactive record | per reference field, the { value, label } rows it offers |
search | (name, text) => Promise<void> | reloads one field's choices |
searchable | reactive record | per reference field, whether its target holds more rows than one load offers |
novalidate keeps the attributes and leaves the verdict to the form: required is still
announced, maxLength still stops the typing, and a refusal lands under its field in the same
words and the same place whether the browser's copy of the rules or the server refused it.
A control left empty, or never touched, is null for a field that admits it — nullable(ref(Author)),
optional(text()) — and absent for any other. No choice is the answer there, so such a field is not
required and its empty option can be taken.
A reference is a choice
A ref(Author) field is a reference control, and field.reference says what it points at:
{ to: 'author', key: 'id', label: 'name' }. The label is the first text() the target shows —
never a writeOnly one — and its key when it shows none. The form loads the first
twenty rows of the target once mounted, into choices.authorId, and search('authorId', 'ali')
reloads the ones whose label contains the text — a list with limit and where, nothing new
on the server. Which widget shows them is the page's: a <select>, or a searchable menu.
<USelectMenu
v-model="values.authorId" :items="choices.authorId ?? []" value-key="value" label-key="label"
ignore-filter @update:search-term="(text) => search('authorId', text)"
/>
Edit mode composes with a query — the facade is aliased because the row is already post:
import { post as postFacade } from '@fronds/facade';
const { data: post } = await useQuery(postFacade, 'findById', { params: { id } });
const form = useFormFor(Post, { op: 'update', params: { id }, initial: post.value ?? undefined });
The browser validates first, and for free
field.attrs is the part of the declaration a browser already enforces, under the names
it knows: type, required, minLength, maxLength, min, max, pattern. Spread it
and the page states no rule of its own — text({ min: 1, max: 200 }) becomes
minLength/maxLength, email() becomes type="email", checked live as one types, with
no JavaScript at all.
This is a projection, not a second rule: the validator reads the same shape. A form that
ignores attrs gets the same verdict, only later — and a screen reader never gets it.
Four attributes are deliberately absent, each where HTML would mean something the declaration does not say:
| Field | Absent | Because |
|---|---|---|
text() | type | text is what an <input> already is, and a page may lay the field out as a <textarea>, which has no type |
date() | type | neither date nor datetime-local produces the RFC 3339 string the validator expects |
bool() | required | on a checkbox it means "must be checked"; the shape says the value must be supplied, and false is one |
oneOf(…), bool() | type | these are not <input>s — field.control says which widget, the page renders it |
What HTML cannot say at all — a cross-field rule, format: 'uuid', a oneOf outside a
select — is caught by the pass below.
The submit cycle
- Local validation —
Entity.validate(payload)runs in the browser with the same rules as the handler. Errors are added toerrorswithout a network call, andsubmit()returnsnull. - Command — the payload is sent through the facade the entity names. On success, active queries for the entity are revalidated.
- Server validation — a returned
VALIDATION_FAILEDis added toerrorsbypath, using the same format as local errors. - Other errors (
CONFLICT,SERVICE_UNAVAILABLE…) are placed inerror. Every refusal answersnull, and none is thrown.
What the form will not do
- Render components — widgets and layout remain in the application.
- Show
readOnlyfields — these fields are excluded by the io projection; a status is displayed as a badge from the loaded data, not editable as an input. - Define labels — they remain in your i18n; the metadata provides stable keys composed of the entity and field name.
- Choose an additional value.
valuesusesinitialwhen editing and the field's declared default when creating (oneOf('public','private',{ default:'public' })opens the select onpublic). Anything beyond those two — a placeholder, a computed suggestion — is defined by the page.
Next: Session — sharing identity with pages and handlers.