Query Often
Forms
A failed save carries typed field errors; one attachment puts them into the form's native state and removes them again.
Markdown version of this page, for assistants (Markdown for AI)
On this page
Field errors
<!-- src/routes/members/[id]/edit/+page.svelte -->
<script lang="ts">
import { createMutation } from '@svelte-lean/query';
import { fieldErrors, issues } from '@svelte-lean/query/forms';
import { updateMember } from '$api/members';
import type { Member } from '$api/types';
import { messageOf } from './messages';
let { member }: { member: Member } = $props();
const save = createMutation(updateMember);
function submit(event: SubmitEvent & { currentTarget: HTMLFormElement }) {
event.preventDefault();
const form = new FormData(event.currentTarget);
save.run({ id: member.id, name: String(form.get('name')), email: String(form.get('email')) });
}
</script>
<form {@attach fieldErrors(() => save.error)} onsubmit={submit} novalidate>
<div data-slean="field">
<label for="member-name">Name</label>
<input id="member-name" name="name" value={member.name} required />
<p id="member-name-error" data-slean-part="error">{issues(save.error, 'name').join(' ')}</p>
</div>
<div data-slean="field">
<label for="member-email">Email</label>
<input id="member-email" name="email" type="email" value={member.email} required />
<p id="member-email-error" data-slean-part="error">{issues(save.error, 'email').join(' ')}</p>
</div>
{#if save.error && save.error.kind !== 'validation'}
<p role="alert">{messageOf(save.error)}</p>
{/if}
<button disabled={save.busy}>Save</button>
</form>The form keeps its own values. A 422 answer (or a refusal with a field map) becomes save.error with kind: 'validation'; the attachment reads it and
writes:
| Written | Where |
|---|---|
aria-invalid="true" | on each control a server message names |
aria-describedby | the id of the field’s error part, added to the ids already there |
setCustomValidity() | the first message, so :invalid styles and the validation bubble apply |
focus | the first invalid control, unless the user is typing in another one |
The error part is the data-slean-part="error" element inside the same data-slean="field". Editing a marked control removes exactly what was written and
hides its outdated message; a new error, reset() or a form reset clears every mark.
Messages
issues(error, path) returns the messages of one field: the exact key, then its
wildcard form, so issues(error, 'lines.0.sku') also finds lines.*.sku. Without a path it returns every issue in server order.
The error is typed by the request body: save.error?.fields?.fields.email exists
only when the body has email, and fields.at('lines', 0, 'sku') walks nested
keys.
Translation
// Translating errors: errorKey() gives a stable reason; the words are the application's.
import type { QueryError } from '@svelte-lean/query';
import { errorKey, type ErrorKey } from '@svelte-lean/query/forms';
const MESSAGES: Record<ErrorKey, string> = {
network: 'You are offline. Try again when the connection is back.',
timeout: 'The server took too long to answer.',
server: 'Something failed on the server.',
'rate-limited': 'Too many requests. Wait a moment.',
'not-found': 'This item no longer exists.',
conflict: 'Someone else changed this item. Reload it.',
http: 'The request failed.',
forbidden: 'You do not have access to this.',
validation: 'Check the marked fields.',
refused: 'The server refused the change.',
parse: 'The answer could not be read.'
};
export const messageOf = (error: QueryError) => error.message || MESSAGES[errorKey(error)];The package shows no text of its own. errorKey() turns an error into a stable
reason (http errors become server, rate-limited, not-found or conflict by status); the words come from the application.
Options
fieldErrors(error, {
name: (path) => path.replace(/^address\./, 'billing_'), // server path → control name
text: (message) => t(message), // translate field messages
unmatched: (rest) => showBanner(rest.map((issue) => issue.message)), // no control for these
focus: true, // focus the first invalid control (default)
validity: true // setCustomValidity(first message) (default)
});| Option | Type | Default | Effect |
|---|---|---|---|
name | (path) => string | dot path, bracket form, then name[] | Maps a server path to a control name. |
text | (message, path) => string | identity | Translates field messages. |
unmatched | (issues) => void | – | Receives the issues with no control in this form. |
focus | boolean | true | Focus the first invalid control. |
validity | boolean | true | Set custom validity on each marked control. |
Guarantees
- Validation errors and refusals are never retried.
- The attachment writes attributes and custom validity only; no element is created.
- A message that looks like an untranslated key (
validation.required) is reported in development by devtools.
Reference
- @svelte-lean/query/forms
- @svelte-lean/query/laravel: how a 422 answer is read
- Field primitive: the label, hint and error markup