A form
useFormFor(Post) gives a page the values, the rules, the submit and the errors of each field, read
from the entity. It renders nothing: the page picks the widgets and lays them out.
<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[] | the fields a client may write — readOnly ones are left out |
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.
It is 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 left out on purpose, where HTML would mean something the entity 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
- In the browser —
Entity.validate(payload)runs with the same rules as the server. A refusal lands inerrorswith no request sent, andsubmit()answersnull. - The command — the payload is sent through the facade named like the entity. On success, the mounted queries on that facade read again.
- On the server — a
VALIDATION_FAILEDthat comes back lands inerrorsby field, in the same words as a local one. - 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 — a status is shown from the loaded row, as a badge, not as an input. - Write labels — they stay in your translations, keyed by entity and field name.
- Invent a value.
valuesstarts frominitialwhen editing and from the field's default when creating —oneOf('public', 'private', { default: 'public' })opens onpublic. A placeholder or a suggestion is the page's.