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:

KeyTypeNotes
fieldsFormField[]derived from the io axes — readOnly fields are excluded
fieldsByNameRecord<string, FormField>the same fields, for a form laid out by hand
valuesreactive row, every field optionalinitial when editing, the field's declared default when creating
errorsreactive recordfield name → message, keyed by the entity's fields
submit() => Promise<Row | null>see cycle below
loadingRef<boolean>
errorRef<FougereError | null>non-validation failure (conflict, dead host)
validComputedRef<boolean>no current field errors
choicesreactive recordper reference field, the { value, label } rows it offers
search(name, text) => Promise<void>reloads one field's choices
searchablereactive recordper 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:

FieldAbsentBecause
text()typetext is what an <input> already is, and a page may lay the field out as a <textarea>, which has no type
date()typeneither date nor datetime-local produces the RFC 3339 string the validator expects
bool()requiredon a checkbox it means "must be checked"; the shape says the value must be supplied, and false is one
oneOf(…), bool()typethese 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

  1. Local validation — Entity.validate(payload) runs in the browser with the same rules as the handler. Errors are added to errors without a network call, and submit() returns null.
  2. Command — the payload is sent through the facade the entity names. On success, active queries for the entity are revalidated.
  3. Server validation — a returned VALIDATION_FAILED is added to errors by path, using the same format as local errors.
  4. Other errors (CONFLICT, SERVICE_UNAVAILABLE…) are placed in error. Every refusal answers null, and none is thrown.

What the form will not do

  • Render components — widgets and layout remain in the application.
  • Show readOnly fields — 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. values uses initial when editing and the field's declared default when creating (oneOf('public','private',{ default:'public' }) opens the select on public). Anything beyond those two — a placeholder, a computed suggestion — is defined by the page.

Next: Session — sharing identity with pages and handlers.

Built with Fougere and Nuxt, hosted on GitHub Pages.