Askr documentation
Guides

Forms, Actions, and CRUD

Forms, Actions, and CRUD: a worked guide from route registry through to a production build.

Example

Bind form input in the server action, return field-aware failures without navigation, and redirect only after a successful write.

<form method="post" action="/projects/new">
  <Field><FieldLabel htmlFor="name">Name</FieldLabel><Input id="name" name="name" required /></Field>
  <Button type="submit">Create project</Button>
</form>

Goal and architecture

Askr's write path is defineAction() plus ActionForm, not a generic onSubmit handler you wire up by hand. defineAction({ id, input, invalidates }) declares a named mutation with an @askrjs/schema input shape and the query keys it should invalidate on success; ActionForm is a native <form> bound to that descriptor, described in the source as explicitly not a synthetic event API. Reads go through createQuery/defineQuery from @askrjs/askr/data, so a CRUD screen is really one query for the list, one action per mutation, and the invalidates list tying them together.

Implementation

For a form-first flow, render <ActionForm action={updateUser}> with normal form fields inside it — no controlled-input plumbing required, since it's a real form submission the framework intercepts. For a programmatic flow (bulk actions, buttons instead of forms), call action(updateUserAction) to get back a state: StateTuple<ActionStatus<TResult>> and a submit(input) function you invoke directly; ActionStatus exposes pending, result, and error so you can drive UI off it without extra state. Either way, list the query keys the action should invalidate — that's what makes the table or detail view refresh after a successful mutation without you calling invalidate() by hand.

Failure states

A rejected action surfaces an ActionValidationError with fieldErrors: Record<string, string[]> keyed by field name — render those next to the relevant inputs instead of a single generic error banner. ActionStatus.error is unioned as unknown, so narrow it before rendering rather than assuming it's always an Error instance or always your validation shape. A submit that never resolves — a hung request, a dropped connection — should still leave pending truthy so a stuck-forever button doesn't look identical to a successful one.

Verification

Submit a form with missing required fields and confirm fieldErrors lines up with the actual field names in your schema, not just a generic failure state. After a successful mutation, confirm every query key listed in invalidates actually refetches — a key typo here is the single most common cause of "I saved it but the list didn't update." Then submit the same action twice quickly and confirm you get the behavior you intended: both requests actually fire — action() doesn't serialize or debounce them — but a generation counter discards the effect of anything but the latest response on UI state and invalidation, even though an earlier write already completed server-side. If your form needs to prevent the earlier write entirely (not just its UI effect), disable the submit control yourself while pending is true.