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.

Runes in a validated form Four layers of a runes-based form: $state for values and touched flags, $derived for errors from rules, $effect mirroring errors into native validity, and the submit handler calling reportValidity. $state values, touched, submitted $derived errors from the rule table $effect setCustomValidity on each input onsubmit reportValidity() → focus + announce
State holds what the user did, derived values hold what is wrong, the effect tells the browser, and submit asks the browser to report it.

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.

Visibility of a runes-derived error A field's error is always derived from its value, but it is hidden while the field is untouched and the form unsubmitted, shown after blur or submit while invalid, and hidden again once the value becomes valid. hidden, untouched hidden, valid shown blur while valid value fixed value broken after touch blur or submit while invalid
Validity is continuous and truthful; visibility waits for the user, which avoids errors before interaction without hiding them from reportValidity.

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.

Svelte 4 stores versus Svelte 5 runes for form state Two columns comparing form validation state held in Svelte 4 writable and derived stores with the same state held in Svelte 5 runes. Svelte 4 stores • writable({ email: "" }) and $: reactive statements • derived(values, …) for errors ✗ subscriptions and $store syntax everywhere ✗ $: can run in surprising order Svelte 5 runes • $state for values and touched flags • $derived for errors ✓ plain property access, no subscriptions ✓ $effect for DOM side effects only
Runes express the same model with less ceremony and no subscriptions to manage.

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.

← Back to Svelte Form Validation