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.