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:

KeyTypeNotes
fieldsFormField[]the fields a client may write — readOnly ones are left out
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.

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:

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. In the browser — Entity.validate(payload) runs with the same rules as the server. A refusal lands in errors with no request sent, and submit() answers null.
  2. The command — the payload is sent through the facade named like the entity. On success, the mounted queries on that facade read again.
  3. On the server — a VALIDATION_FAILED that comes back lands in errors by field, in the same words as a local one.
  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 — 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. values starts from initial when editing and from the field's default when creating — oneOf('public', 'private', { default: 'public' }) opens on public. A placeholder or a suggestion is the page's.
Built with Fougere and Nuxt, hosted on GitHub Pages.