Skip to content
Meeko
Type to search the guides and the API reference. Open it from any page with / or Ctrl K; move with the arrow keys and open a result with Enter.
Get started

Contents

On this page

useForm

Component form state, with one signal per field, Standard Schema validation, server errors per field, submission and reset.

composable Since 1.2.0 No import @meekojs/meeko/framework Source code

Se autoimporta en los componentes y en app/.

ts
import { useForm } from "@meekojs/meeko/framework";

useForm brings together recurring form needs: a writable signal per field for model, validation with a Standard Schema (such as Valibot's) on submission, issues from a server 400 distributed by field, a whole-form message, pending, submitted, dirty, reset, load and commit. Call it at the top level of a component's <script>; it is auto-imported in framework applications.

Form, FormField and FormSubmission are also auto-imported as types in component <script> and app/ code. In server/, shared/ or <script server> code, an explicit import is required if you only need to describe a type.

The form is component state: it does not travel in the payload. The server renders it with initial and the browser recreates it with the same initial during hydration.

Usage#

meeko
<!-- app/pages/categories/new.meeko -->
<script lang="ts">
import * as v from "valibot";

const categoryInput = v.object({
  name: v.pipe(v.string(), v.trim(), v.minLength(1, "Enter a name.")),
});

const form = useForm({
  schema: categoryInput,
  initial: { name: "" },
  submit: (values) => api.categories.post({ body: values }),
});
</script>

<template>
  <form on:submit="form.submit" novalidate>
    <p if:when="form.error.value" role="alert">{form.error.value}</p>
    <label>Name <input model="form.fields.name"></label>
    <p if:when="form.errors.value.name">{form.errors.value.name}</p>
    <button bind:disabled="form.pending.value">Save</button>
  </form>
</template>

on:submit="form.submit" passes the function as a handler: submit calls event.preventDefault(), validates with the schema and, if data is valid, runs your submit with the schema output.

Type#

ts
function useForm<S extends FormSchema<object, unknown>, Result = unknown>(
  options: FormOptions<Complete<SchemaInput<S>>, SchemaOutput<S>, Result> & {
    readonly schema: S;
  },
): Form<Complete<SchemaInput<S>>, Result>
ts
function useForm<Values extends object, Result = unknown>(
  options: FormOptions<Values, Values, Result> & { readonly schema?: undefined },
): Form<Values, Result>

Form

ts
interface Form<Values, Result> {
	readonly fields: { readonly [K in keyof Values]: Signal<Values[K]> }
	readonly errors: ReadonlySignal<{ readonly [P in Paths<Values>]?: string }>
	readonly error: ReadonlySignal<string | null>
	readonly failure: ReadonlySignal<Error | null>
	readonly pending: ReadonlySignal<boolean>
	readonly submitted: ReadonlySignal<boolean>
	readonly dirty: ReadonlySignal<boolean>
	readonly submit: (event?: Event) => Promise<Result | undefined>
	readonly reset: { (values?: Partial<Values>): void; (event: Event): void }
	readonly load: (values: Partial<Values>) => void
	readonly trySubmit: (event?: Event) => Promise<FormSubmission<Result>>
	readonly commit: (values?: Partial<Values>) => void
	readonly field: <K extends keyof Values & string>(name: K) => FormField<Values[K]>
	readonly csrfToken: string | undefined
}

FormSubmission

ts
type FormSubmission<Result> =
| {
		readonly ok: true
		readonly result: Result
  }
| {
		readonly ok: false
		readonly reason: 'invalid'
		readonly errors: Readonly<Record<string, string>>
		readonly error: string | null
		readonly failure: Error | null
  }
| {
		readonly ok: false
		readonly reason: 'error'
		readonly error: string | null
		readonly failure: Error
  }
| {
		readonly ok: false
		readonly reason: 'skipped'
  }

FormField

ts
interface FormField<Value> {
	readonly name: string
	readonly id: string
	readonly errorId: string
	readonly hintId: string
	readonly value: Signal<Value>
	readonly error: ReadonlySignal<string | undefined>
	readonly invalid: ReadonlySignal<boolean>
	readonly describedBy: ReadonlySignal<string | undefined>
}

Options#

useForm(options) takes an object:

Option Type Default Description
schema Standard Schema v1 no schema Validates fields on submission. With a schema, fields have its input type and submit receives its output (a v.transform can convert text to a number). Without a schema, only the server validates.
initial Values required Each field's initial value. With a schema, one key per input key, including optional keys (with undefined). Must depend only on props, route data and state, because the browser reevaluates it during hydration.
submit (values) => Result | PromiseLike<Result> required Sends valid data and returns the result, or rejects. Receives schema output, or field values without a schema.
errorMessage (failure: unknown) => string failure.message error text for a failure without issues, such as a 409 or network failure. For a ClientError, the default message is its code.
onSuccess (result, form) => void none Since 1.5. Runs after successful submission, with submitted already true. An error it throws goes to the component's error observer and does not change the submission result.
focusInvalid boolean false Since 1.5. After a submission with field errors, focuses the first invalid control in the event's <form> once messages render.
action string none Since 1.7. Endpoint path with form receiving the <form method="post"> without JavaScript, such as "/api/feedback". On return from submission, SSR renders its result: errors, error and failure, or submitted, and values of fields listed by the endpoint in form.echo (none by default: other fields start from initial).

Return value#

A Form<Values, Result>. fields are writable signals; other signals are read-only and csrfToken is a string.

Property Type Description
fields { [K in keyof Values]: Signal<Values[K]> } One signal per field, for model on <input>, <textarea> and <select>, and model:nombre on a component.
errors ReadonlySignal<{ [ruta]?: string }> First message for each error path: errors.value.name, errors.value["items.0.name"]. A field without an error has no key.
error ReadonlySignal<string | null> Whole-form message: an issue at path "", an issue for a field absent from the form or errorMessage text.
failure ReadonlySignal<Error | null> Original error from the latest failed submission, including a 400's ClientError.
pending ReadonlySignal<boolean> true while an asynchronous schema validates or submit runs.
submitted ReadonlySignal<boolean> true when the latest submission succeeded.
dirty ReadonlySignal<boolean> true if any field differs from initial values. Compares arrays, plain objects and dates by content.
submit(event?) Promise<Result | undefined> Submits. Resolves with your submit's result, or undefined if data is invalid, submission fails or another is already in progress. Never rejects.
trySubmit(event?) Promise<FormSubmission<Result>> Since 1.5. Submits similarly, but resolves with the outcome: { ok: true, result } or { ok: false, reason } with "invalid", "error" or "skipped". Never rejects.
reset(values?) void Returns to initial, even after load or commit. If given values, merges them with initial and takes the result as the dirty baseline. Accepts the native reset event. Clears errors and the latest submission state.
load(values) void Merges a record with current values, takes the result as the dirty baseline and clears errors and the latest submission state. reset() still returns to initial.
commit(values?) void Since 1.5. Writes values and takes current values as the new dirty baseline: returns to false. Does not clear submitted, errors, error or failure.
field(name) FormField<Values[K]> Since 1.5. Field pieces for a custom component: signal, message, invalid, name and IDs stable between SSR and hydration. Always returns the same object for the same field.
csrfToken string | undefined Since 1.7. Session CSRF token for a native form's hidden _csrf field, or undefined without a session: in SSR, the request's token; in the browser, from the meeko-csrf-token meta tag. A string, not a signal.

Examples#

Server errors in each field#

Without a browser schema, the server validates. The 400 response for input failing a defineApiRoute schema includes issues with path and message, and useForm puts each message in its field. An ApiError with issues (since 1.5) does the same with another status, such as a 409:

ts
// server/api/signup.post.ts
import { ApiError, defineApiRoute } from "@meekojs/meeko/server";
import { signupInput, signupResult } from "../../shared/schemas/signup";

export default defineApiRoute({
  body: signupInput,
  response: signupResult,
  status: 201,
  handler: async ({ body }) => {
    if (await emailTaken(body.email)) {
      throw new ApiError("EMAIL_TAKEN", 409, { issues: [{ path: "email", message: "That email already has an account." }] });
    }
    return createUser(body);
  },
});
meeko
<!-- app/pages/signup.meeko -->
<script lang="ts">
const form = useForm({
  initial: { email: "", password: "" },
  submit: (values) => api.signup.post({ body: values }),
  errorMessage: (failure) => isClientError(failure) ? "Could not create the account." : "Could not connect. Try again.",
});
</script>

<template>
  <form on:submit="form.submit" novalidate>
    <p if:when="form.error.value" role="alert">{form.error.value}</p>
    <label>Email <input type="email" model="form.fields.email"></label>
    <p if:when="form.errors.value.email">{form.errors.value.email}</p>
    <label>Password <input type="password" model="form.fields.password"></label>
    <p if:when="form.errors.value.password">{form.errors.value.password}</p>
    <button bind:disabled="form.pending.value">Create account</button>
  </form>
</template>

With the 409, form.errors.value.email is “That email already has an account.”, form.error.value is null and form.failure.value is the ClientError. Without a schema, changing the field clears its server message.

Edit a record and save as a new baseline#

initial comes from page data. After saving, onSuccess calls commit with the server's result: dirty returns to false and submitted's “Saved” remains.

If the record arrives after component creation, call form.load({ name: record.name, bio: record.bio }) when loading ends. load marks those values clean; form.reset() returns to declared initial values.

meeko
<!-- app/pages/profile.meeko -->
<script server lang="ts">
export const profile = await loadProfile(usePage().context);
</script>

<script lang="ts">
const form = useForm({
  schema: profileInput,
  initial: { name: profile.name, bio: profile.bio },
  submit: (values) => api.profile.put({ body: values }),
  onSuccess: (saved, form) => form.commit({ name: saved.name, bio: saved.bio }),
});
</script>

<template>
  <form on:submit="form.submit">
    <input model="form.fields.name">
    <textarea model="form.fields.bio"></textarea>
    <button bind:disabled="!form.dirty.value || form.pending.value">Save</button>
    <p if:when="form.submitted.value && !form.dirty.value">Saved.</p>
  </form>
</template>

Know how submission ended#

submit resolves to undefined both for invalid data and network failure. trySubmit distinguishes the cases:

ts
import { signal } from "@meekojs/meeko";
import { useForm } from "@meekojs/meeko/framework";

const lastSaved = signal<string | null>(null);

const form = useForm({
  initial: { title: "" },
  submit: async (values): Promise<{ updatedAt: string }> => {
    const response = await fetch("/api/notes", { method: "POST", body: JSON.stringify(values) });
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    return (await response.json()) as { updatedAt: string };
  },
});

async function save(event: SubmitEvent): Promise<void> {
  const outcome = await form.trySubmit(event);
  if (outcome.ok) lastSaved.value = outcome.result.updatedAt;
  else if (outcome.reason === "error") console.warn(outcome.failure);
  // "invalid": messages are already in form.errors; "skipped": another submission was in progress.
}

A field component with form.field#

form.field(name) gives a field component everything it needs without receiving the whole form, with matching IDs in server HTML and hydration:

meeko
<!-- app/components/AppField.meeko -->
<script lang="ts">
import type { FormField } from "@meekojs/meeko/framework";

const props = defineProps<{ field: FormField<string>; label: string; type?: string }>();
</script>

<template>
  <label bind:for="props.field.id">{props.label}</label>
  <input bind:id="props.field.id" bind:name="props.field.name" bind:type="props.type ?? 'text'" model="props.field.value"
    bind:aria-invalid="props.field.invalid.value ? 'true' : null" bind:aria-describedby="props.field.describedBy.value">
  <p if:when="props.field.error.value" bind:id="props.field.errorId">{props.field.error.value}</p>
</template>
meeko
<form on:submit="form.submit" novalidate>
  <AppField bind:field="form.field('email')" label="Email" type="email" />
  <AppField bind:field="form.field('password')" label="Password" type="password" />
</form>

An optional checkbox with a default#

Fields have the schema's input type, even when a key has a default. With consent: v.optional(v.boolean(), false), form.fields.consent is Signal<boolean | undefined> and form.field("consent") is FormField<boolean | undefined>, even though initial starts it at false and submit receives a boolean. Since 1.7.1, model on an <input type="checkbox"> accepts that signal and treats undefined as unchecked. A field component declares the same type it receives:

meeko
<!-- app/components/AppCheckbox.meeko -->
<script lang="ts">
import type { FormField } from "@meekojs/meeko/framework";

const props = defineProps<{ field: FormField<boolean | undefined>; label: string }>();
</script>

<template>
  <label><input type="checkbox" bind:id="props.field.id" bind:name="props.field.name" model="props.field.value"> {props.label}</label>
  <p if:when="props.field.error.value" bind:id="props.field.errorId">{props.field.error.value}</p>
</template>

A FormField<boolean> does not accept that field (TS2322): the output type cannot be used for the field, because the same type describes a schema whose input genuinely allows undefined until the user chooses.

A form working without JavaScript (since 1.7)#

With form on the endpoint and action in useForm, the same form submits JSON with JavaScript and as a native <form method="post"> before hydration or without JavaScript. Each control has its field's name:

ts
// server/api/feedback.post.ts
import { defineApiRoute } from "@meekojs/meeko/server";
import { feedbackInput, feedbackSaved } from "../../shared/schemas/feedback.js";

export default defineApiRoute({ body: feedbackInput, response: feedbackSaved, form: { echo: ["comment"] }, handler: ({ body }) => saveFeedback(body) });
meeko
<!-- app/components/Feedback.meeko -->
<script lang="ts">
const route = useRoute();
const form = useForm({
  schema: feedbackInput,
  initial: { page: route.path.value, helpful: false, comment: "" },
  action: "/api/feedback",
  submit: (values) => api.feedback.post({ body: values }),
});
</script>

<template>
  <form method="post" action="/api/feedback" on:submit="form.submit" novalidate>
    <input type="hidden" name="return" bind:value="route.url.value">
    <input type="hidden" name="_csrf" bind:value="form.csrfToken ?? ''">
    <input type="hidden" name="page" bind:value="form.fields.page.value">
    <p if:when="form.submitted.value">Thank you!</p>
    <p if:when="form.error.value" role="alert">{form.error.value}</p>
    <button name="helpful" value="true" on:click="() => { form.fields.helpful.value = true; }">Yes</button>
    <button name="helpful" value="false" on:click="() => { form.fields.helpful.value = false; }">No</button>
    <textarea name="comment" model="form.fields.comment"></textarea>
    <p if:when="form.errors.value.comment">{form.errors.value.comment}</p>
  </form>
</template>

Without JavaScript, the server validates with the same body schema, responds with 303 back to return and stores the result in a single-use cookie; page SSR renders it in the form whose action matches, and hydration preserves it. Only form.echo field values return (here, the comment); remaining fields display initial.

Confirmation depending on another field#

Since 1.5, each change also revalidates fields already showing a schema error. With a Valibot rule placing the error on confirm, correcting the password removes “Passwords do not match” without touching confirmation:

ts
import * as v from "valibot";

export const passwordInput = v.pipe(
  v.object({ password: v.pipe(v.string(), v.minLength(10, "Use at least 10 characters.")), confirm: v.string() }),
  v.forward(
    v.partialCheck([["password"], ["confirm"]], (input) => input.password === input.confirm, "Passwords do not match."),
    ["confirm"],
  ),
);

Common mistakes#

Pass .value to model#

model needs the field's signal, not its value.

Incorrectmeeko
<input model="form.fields.name.value">

meeko check reports TS2322: Type 'string' is not assignable to type '{ value: string; }' on the attribute, and at runtime the binding throws TypeError: Meeko model expects a writable signal, not a computed signal or a plain value.

Correctmeeko
<input model="form.fields.name">

Call submit in the handler#

on:submit expects a function. form.submit() runs during rendering and passes a promise.

Incorrectmeeko
<form on:submit="form.submit()">

meeko check reports TS2345: Argument of type 'Promise<…>' is not assignable to parameter of type '(event: SubmitEvent & { readonly currentTarget: HTMLFormElement; }) => unknown'.

Correctmeeko
<form on:submit="form.submit">

To do something with the result, wrap the call in a script function: async function save(event: SubmitEvent) { const saved = await form.submit(event); … } and on:submit="save".

A nonexistent field#

Fields come from initial (or schema input), so a misspelled name is a type error in fields, errors and field:

Incorrectmeeko
<input model="form.fields.nmae">
<p>{form.errors.value.nmae}</p>
<AppField bind:field="form.field('nmae')" label="Name" />

meeko check reports TS2339: Property 'nmae' does not exist on type '{ readonly name: Signal<string>; }' in fields and errors, and TS2345: Argument of type '"nmae"' is not assignable to parameter of type '"name"' in field. If the name arrives untyped, form.field throws TypeError: useForm(): the form has no field "nmae" at runtime.

Correctmeeko
<input model="form.fields.name">
<p>{form.errors.value.name}</p>
<AppField bind:field="form.field('name')" label="Name" />

A numeric field in an <input type="number">#

An <input>, including type number, provides text. model requires a string signal.

Incorrectts
const form = useForm({ initial: { age: 0 }, submit: save });
// <input type="number" model="form.fields.age">

meeko check reports TS2322: Type 'Signal<number>' is not assignable to type '{ value: string; }'. Store text in the field and convert it in the schema, whose output is received by submit:

Correctts
const ageInput = v.object({ age: v.pipe(v.string(), v.transform(Number), v.integer("Enter an integer.")) });
const form = useForm({ schema: ageInput, initial: { age: "" }, submit: (values) => save(values) }); // values.age is number
// <input type="number" model="form.fields.age">

initial missing schema keys#

With a schema, initial needs a value per input key, including optional ones.

Incorrectts
const signupInput = v.object({ name: v.string(), email: v.string() });
const form = useForm({ schema: signupInput, initial: { name: "" }, submit });

meeko check reports TS2769: No overload matches this call with Property 'email' is missing in type '{ name: string; }' but required in type 'Complete<{ name: string; email: string; }>'.

Correctts
const form = useForm({ schema: signupInput, initial: { name: "", email: "" }, submit });

Mutate a field's array#

A signal notifies when assigned a new value. push changes the same array: neither the template nor dirty is notified, and no error flags it.

Incorrectts
form.fields.tags.value.push(tag);
Correctts
form.fields.tags.value = [...form.fields.tags.value, tag];

Treat csrfToken as a signal#

csrfToken is a string, or undefined without a session.

Incorrectmeeko
<input type="hidden" name="_csrf" bind:value="form.csrfToken.value">

meeko check reports TS18048: 'form.csrfToken' is possibly 'undefined' and TS2551: Property 'value' does not exist on type 'string'. Did you mean 'valueOf'?.

Correctmeeko
<input type="hidden" name="_csrf" bind:value="form.csrfToken ?? ''">

Notes#

  • Where to call it. At the top level of <script>, before any await, like useId: form.field IDs come from useId and depend on call order. Does not need await or have useApi's MEEK054 restriction.
  • SSR. The server renders the form with initial and never submits: submit or trySubmit called while the script only produces HTML (SSR or a navigation dry run) resolves with { ok: false, reason: "skipped" }.
  • Hydration. Fields neither travel in the payload nor survive HMR: the browser reruns useForm with the same initial. User input before hydration is preserved because model copies it to the field signal when hydration ends. Since 1.7, with action, the result of a non-JavaScript submission does travel in page state so hydration renders the same as SSR; the browser uses it once.
  • Validation timing. The schema runs on submission, not while typing. After an error, the field is revalidated on every change and its message disappears once valid; an error-free field is not validated until the next submission. With an asynchronous schema, pending is true while validating, and only the latest validation applies.
  • Concurrent submissions. While a submission is in flight, another submit is ignored (resolves to undefined) and also prevents native browser submission.
  • Unexpected failures. A ClientError is an expected server response. Any other error (network failure, an error in your submit or a throwing schema) also reaches the component's error observer, provideErrorHandler or mountClientApp's onError, with source "event"; without an observer, it is written to the console.
  • Security without JavaScript. A native submission requires an allowed Origin (403 INVALID_ORIGIN if absent) and, with a session, the token in _csrf (403 INVALID_CSRF). return and _csrf never reach the schema.
  • Accessibility. Mark controls with aria-invalid and link messages with aria-describedby (form.field provides describedBy). With focusInvalid: true, focus goes to the first invalid control, found by name, form.field's id or aria-invalid="true".

Note

meeko check checks initial against schema input, fields names, errors paths and that each model has the control's type. Run it in CI: it reports every type error on this page before reaching the browser.

Was this page helpful?

Add a comment

It is sent when you choose “Yes” or “No”. Only the page, your answer and the comment are stored: no cookies and no personal data.

0 of 2000 characters

Edit on GitHub: docs/en/reference/composables/useform.md