Svelte 5 Runes Form Validation
How do you write form validation in Svelte 5 using only runes — no stores, no form library — so that errors are computed from the current values, shown only after the user has touched a field or submitted, kept in step with native validity, and delivered with focus on the first problem? This recipe puts values and touched flags in $state, derives errors with $derived from a small rule table (or a schema), mirrors them into setCustomValidity() with $effect so the browser knows about them, and calls reportValidity() from the Constraint Validation API on submit. The result is a component with one source of truth for errors that works with native constraints rather than beside them.
When to Use Runes Without a Library
A runes-only approach fits:
- Small to medium forms — sign-in, contact, settings — where a library adds more concepts than it removes.
- Components in a design system that must not depend on a particular form library.
- Client-only forms in Svelte apps without SvelteKit form actions, such as widgets embedded in other pages.
For large SvelteKit apps with many forms and nested data, SvelteKit Superforms with Zod removes boilerplate. For the action-based alternative that derives messages from each node’s validity, see the Svelte form validation topic.
Minimal Working Runes Form
<script lang="ts">
type Field = "email" | "password" | "confirm";
// 1. What the user did
let values = $state<Record<Field, string>>({ email: "", password: "", confirm: "" });
let touched = $state<Record<Field, boolean>>({ email: false, password: false, confirm: false });
let submitted = $state(false);
// 2. What is wrong — pure functions of the current values
const rules: Record<Field, (v: typeof values) => string> = {
email: (v) => !v.email ? "Enter your email address." : /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v.email) ? "" : "Enter an email address like name@example.com.",
password: (v) => [...v.password].length >= 12 ? "" : "Password must be at least 12 characters.",
confirm: (v) => v.confirm === v.password ? "" : "Passwords don't match.",
};
const errors = $derived(Object.fromEntries(
(Object.keys(rules) as Field[]).map((f) => [f, rules[f](values)]),
) as Record<Field, string>);
const visible = (f: Field) => (touched[f] || submitted) && Boolean(errors[f]);
// 3. Tell the browser: native validity mirrors the derived errors
let formEl: HTMLFormElement;
$effect(() => {
for (const f of Object.keys(errors) as Field[]) {
const input = formEl.elements.namedItem(f) as HTMLInputElement | null;
input?.setCustomValidity(errors[f]);
}
});
// 4. Submit through the canonical path
function onsubmit(event: SubmitEvent) {
event.preventDefault();
submitted = true;
if (!formEl.reportValidity()) return; // focuses and announces the first invalid field
formEl.submit();
}
</script>
<form bind:this={formEl} novalidate {onsubmit}>
{#each [["email", "Email address", "email", "email"], ["password", "Password", "password", "new-password"], ["confirm", "Confirm password", "password", "new-password"]] as [name, label, type, ac]}
{@const f = name as Field}
<div class="field">
<label for={f}>{label}</label>
<input id={f} name={f} {type} autocomplete={ac} bind:value={values[f]}
onblur={() => (touched[f] = true)}
aria-invalid={visible(f) ? "true" : undefined}
aria-describedby="{f}-err" />
<p id="{f}-err" class="field-error" hidden={!visible(f)}>{visible(f) ? errors[f] : ""}</p>
</div>
{/each}
<button type="submit">Create account</button>
</form>
Because errors is $derived, it is always correct for the current values — there is no code path that forgets to clear an error. The touched || submitted gate decides only visibility, so the browser’s validity (via the effect) is always truthful even for fields the user has not reached yet, which is exactly what reportValidity() needs on submit.
Runes Validation Reference
| Rune / API | Role | Notes |
|---|---|---|
$state |
Values, touched flags, submitted flag | Deep reactivity for objects |
$derived |
Errors computed from values | Never stale; recomputed on change |
$effect |
setCustomValidity on inputs |
Runs after DOM updates |
bind:value |
Two-way binding | Keep name on inputs for FormData |
onblur / onsubmit |
Svelte 5 event attributes | Replace on:blur / on:submit |
reportValidity() |
Focus and announcement | Uses the validity the effect set |
hidden on the message |
Visibility | Keeps aria-describedby target stable |
Verification Steps
import { render, screen } from "@testing-library/svelte";
import userEvent from "@testing-library/user-event";
import { expect, it } from "vitest";
import Signup from "./Signup.svelte";
it("derives the confirmation error from both fields", async () => {
render(Signup);
await userEvent.type(screen.getByLabelText("Password"), "correct horse battery");
await userEvent.type(screen.getByLabelText("Confirm password"), "correct horse battery");
await userEvent.tab();
expect(screen.getByLabelText("Confirm password")).not.toHaveAttribute("aria-invalid");
await userEvent.type(screen.getByLabelText("Password"), "!");
expect(screen.getByLabelText("Confirm password")).toHaveAttribute("aria-invalid", "true");
});
Edge Cases and Failure Modes
Writing errors into $state. Keeping errors in $state and updating them in handlers reintroduces stale-error bugs that $derived eliminates. Derive them.
Effects that set state they read. An $effect that updates values or touched based on errors creates loops. Effects here only touch the DOM (setCustomValidity), never state.
Native constraints and derived rules disagreeing. If you also put minlength="12" on the password input, native tooShort and your derived rule can produce two messages. Either rely on native constraints and read validationMessage, or keep all rules in the derived table and omit the attributes — the second is simpler with runes, the first works before hydration.
Server errors. A server response can be merged as a separate $state object (serverErrors) that the derived errors fall back to, cleared per field on the next input, so server and client errors render through the same markup — the approach described in mapping server field errors to form inputs.
Asynchronous Rules With Runes
Async checks do not fit in $derived, which must be synchronous. Model them as separate state: a $state object holding each async verdict ("idle" | "checking" | "taken" | "ok") and the value it applies to, updated by a debounced function that aborts the previous request. The derived errors then include the async verdict only when it applies to the current value, so a verdict for an old value can never leak into the error list. During "checking", set a pending custom validity so a fast submit waits, and let the submit handler await any in-flight promise before calling reportValidity(). This keeps the single-source-of-truth property: errors is still derived, now from values plus the latest verdicts, and nothing needs clearing by hand when the user edits.
let usernameCheck = $state<{ value: string; verdict: "idle" | "checking" | "ok" | "taken" }>({ value: "", verdict: "idle" });
const usernameError = $derived(
usernameCheck.value === values.username && usernameCheck.verdict === "taken" ? "That username is taken." :
usernameCheck.value === values.username && usernameCheck.verdict === "checking" ? "Checking availability…" : "",
);
The same state can drive a visible status line (“Checking…”, “Available”) referenced from the input’s aria-describedby, with a single polite announcement when the check completes, as described in showing valid field checkmarks accessibly.
Rendering Order and Stable Descriptions
The message element is always rendered, and only its hidden attribute and text change. That keeps the aria-describedby reference stable, which matters because some screen readers cache a field’s description and do not re-read targets that appear later. Rendering the message with {#if visible(f)} instead would create and destroy the element, so the reference would point at nothing until the first error. Keeping the element also reserves a predictable place for the message in the layout.
Extracting a Reusable createForm Helper
Once two or three forms follow the same shape, extract the runes into a helper in a .svelte.ts module — runes work in those files — so each form declares only its rules. The helper returns reactive values, derived errors, a visible function and the submit handler; components stay declarative.
// lib/forms/createForm.svelte.ts
export function createForm<F extends string>(initial: Record<F, string>, rules: Record<F, (v: Record<F, string>) => string>) {
const values = $state({ ...initial });
const touched = $state(Object.fromEntries(Object.keys(initial).map((k) => [k, false])) as Record<F, boolean>);
let submitted = $state(false);
const errors = $derived(Object.fromEntries((Object.keys(rules) as F[]).map((f) => [f, rules[f](values)])) as Record<F, string>);
return {
values, touched, get errors() { return errors; },
visible: (f: F) => (touched[f] || submitted) && Boolean(errors[f]),
submit(form: HTMLFormElement): boolean { submitted = true; return form.reportValidity(); },
};
}
The helper keeps the same principles as the component: one derived source of truth, visibility gated separately, and the browser’s reportValidity() as the final word. It is also easy to unit test with Vitest, since the rule functions are pure — the testing approach in unit testing validation logic applies directly.
Frequently Asked Questions
How do I validate a form with Svelte 5 runes?
Keep values and touched flags in $state, compute errors with $derived from pure rule functions, mirror the errors into each input with setCustomValidity inside an $effect, and call reportValidity on submit.
Should errors be $state or $derived?
$derived. Errors are a function of the values, so deriving them means they are always correct and never need clearing by hand. Use $state only for what the user did.
How do I avoid showing errors before the user interacts?
Gate visibility, not validity. Show an error only when the field is touched or the form has been submitted, while still setting custom validity so reportValidity works on submit.
Can I use runes outside components for shared form logic?
Yes. Put the helper in a .svelte.ts or .svelte.js module, where runes are allowed, and import it into components.
Related Guides
- Svelte Form Validation — the Svelte validation overview.
- SvelteKit Superforms with Zod — the library alternative.
- Cross-Field Password Confirmation Logic — the derived confirmation rule.
- Tracking Dirty, Touched and Pristine State — the touched flags in depth.