Exact problem: a SvelteKit form runs validation or mutates ARIA attributes synchronously during the hydration window, causing Svelte to detect a server/client DOM divergence and log a hydration mismatch warning β which also corrupts aria-invalid state and triggers visible UI flicker before the user has touched anything.
Context and prerequisites
This page drills into one specific failure mode inside the broader topic of hydration sync for SSR forms. Before reading on, you should be familiar with how SvelteKitβs $page.form object carries server action results back to the client. You should also understand Svelte store integration for forms because the fix wraps that same store pattern with a lifecycle gate.
The diagram below shows the two timelines β server render and client hydration β and the narrow window where premature validation causes the mismatch:
Core pattern: the hydration gate
The single implementation below addresses the entire mismatch class. Every non-obvious line carries an inline comment.
<script lang="ts">
import { onMount, tick } from 'svelte';
import { writable, derived, type Readable } from 'svelte/store';
import { beforeNavigate } from '$app/navigation';
// ββ Types ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
interface FormData {
email: string;
username: string;
[key: string]: string;
}
type FieldErrors = Record<string, string>;
// ββ Props ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
export let form: Partial<FormData> = {};
// ββ Stores βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// Wrap the server-supplied prop in a store so derived() can subscribe to it.
// A plain prop cannot be used inside derived() β stores are required.
const formStore = writable<Partial<FormData>>(form);
$: formStore.set(form); // mirror future server-action updates reactively
// The hydration gate. Stays false until Svelte finishes reconciling the DOM.
// NEVER set this true synchronously β doing so re-introduces the mismatch.
const isHydrated = writable(false);
const validationErrors = writable<FieldErrors>({});
// Derived validity bypasses all schema checks while the gate is closed.
// This is what prevents aria-invalid from being set during the danger zone.
const isValid: Readable<boolean> = derived(
[formStore, validationErrors, isHydrated],
([$form, $errors, $hydrated]) => {
if (!$hydrated) return true; // treat as valid during hydration β no attribute mutations
return Object.keys($errors).length === 0;
}
);
// ββ Lifecycle βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
onMount(async () => {
// tick() yields to the microtask queue, giving Svelte one full pass to
// reconcile server HTML with the initial client vdom. Only after that
// completes is it safe to mutate DOM-visible reactive state.
await tick();
isHydrated.set(true);
// Return cleanup: runs on component destroy (navigation away or unmount).
return () => {
isHydrated.set(false); // reset so the gate closes if component remounts
validationErrors.set({}); // clear stale errors β they don't belong to the next route
};
});
// Reset the gate on SvelteKit client-side navigation to prevent stale state
// from a previous route leaking into the next one during the transition.
beforeNavigate(() => {
isHydrated.set(false);
validationErrors.set({});
});
// ββ Validation ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// AbortController is used here to cancel any async uniqueness checks if the
// user types again before the previous request resolves. Without this, a
// slow response can overwrite a newer, correct validation result.
let abortController: AbortController | null = null;
async function validateField(name: string, value: string): Promise<void> {
// Hard guard: never run validation during the hydration window.
// $isHydrated reads the current store value synchronously via the $ prefix.
if (!$isHydrated) return;
// Cancel any in-flight async validation for this field before starting a new one.
abortController?.abort();
abortController = new AbortController(); // each call gets a fresh token
const signal = abortController.signal; // pass signal into fetch() to cancel
try {
const result = runSchemaValidation($formStore);
// If the signal aborted while runSchemaValidation was executing, discard the result.
if (signal.aborted) return;
validationErrors.update(errors => ({
...errors,
[name]: result[name] ?? '',
}));
} catch (err) {
// AbortError is expected and benign β swallow it silently.
if (err instanceof Error && err.name !== 'AbortError') {
console.error('[form] validation error:', err);
}
}
}
function handleInput(event: Event): void {
const target = event.target as HTMLInputElement;
const { name, value } = target;
formStore.update(f => ({ ...f, [name]: value }));
void validateField(name, value);
}
function handleSubmit(event: SubmitEvent): void {
event.preventDefault();
// Full-form validation and submission logic goes here.
}
// Stub β replace with your actual schema library (Zod, Valibot, etc.)
function runSchemaValidation(data: Partial<FormData>): FieldErrors {
const errors: FieldErrors = {};
if (!data.email?.includes('@')) errors.email = 'Enter a valid email address.';
if (!data.username || data.username.length < 3) errors.username = 'Username must be at least 3 characters.';
return errors;
}
</script>
<form on:submit={handleSubmit} novalidate>
<div class="field">
<label for="email">Email</label>
<input
id="email"
name="email"
type="email"
value={$formStore.email ?? ''}
on:input={handleInput}
aria-invalid={$validationErrors.email ? 'true' : 'false'}
aria-describedby={$validationErrors.email ? 'email-error' : undefined}
/>
{#if $validationErrors.email && $isHydrated}
<!-- role="alert" triggers screen-reader announcement on insertion -->
<span id="email-error" role="alert" class="error-text">
{$validationErrors.email}
</span>
{/if}
</div>
<div class="field">
<label for="username">Username</label>
<input
id="username"
name="username"
type="text"
value={$formStore.username ?? ''}
on:input={handleInput}
aria-invalid={$validationErrors.username ? 'true' : 'false'}
aria-describedby={$validationErrors.username ? 'username-error' : undefined}
/>
{#if $validationErrors.username && $isHydrated}
<span id="username-error" role="alert" class="error-text">
{$validationErrors.username}
</span>
{/if}
</div>
<button type="submit">Submit</button>
</form>
Step-by-step walkthrough
-
Wrap the prop in a store (lines
formStore = writable(form)and$: formStore.set(form)). A component prop cannot be passed directly toderived(). Wrapping it lets the derived validity store subscribe and react when the server sends a new$page.formpayload after a form action round-trip. -
Create
isHydratedas awritable(false)store. This is the gate. All reactive expressions that would mutate DOM-visible attributes read this store viaderived(), so they evaluate to safe defaults while the gate is closed. -
Open the gate inside
onMountafterawait tick().onMountruns after the component first renders on the client.tick()yields until Svelte finishes its DOM reconciliation pass. SettingisHydratedtotruebefore that pass completes is the most common mistake β it re-introduces the very mismatch you are trying to prevent. -
Return a cleanup function from
onMount. Svelte calls the returned function when the component is destroyed. ResettingisHydratedandvalidationErrorsensures that if this component is ever re-mounted (e.g., in a Svelte 5{#snippet}context), it starts clean rather than inheriting stale state from a previous mount. -
Register
beforeNavigateto reset both stores. SvelteKitβs client-side router does not destroy and recreate components on every navigation. Without this, the gate remains open from the previous route and the next routeβs hydration is unprotected. -
Gate
validateFieldwithif (!$isHydrated) return. This is the inline guard β a second line of defence in case the derived storeβs value has not yet propagated when an input event fires very early. -
Use
AbortControllerto cancel in-flight async validation. Each call tovalidateFieldaborts the previous controller and creates a fresh one. Thesignalis checked after any async operation to discard stale results β this is the pattern described in implementing async email availability checks.
Failure modes and edge cases
1. Setting isHydrated = true without await tick()
If you call isHydrated.set(true) synchronously inside onMount, the derived store re-evaluates before Svelte reconciles the DOM. The aria-invalid attributes are now set based on client state while the server HTML still has different attribute values β Svelte detects the mismatch and logs the warning.
Fix: always await tick() before opening the gate.
2. Browser autofill fires before onMount
Browsers can autofill input values immediately after parsing the HTML, which can trigger input events before onMount runs. The inline if (!$isHydrated) return guard in validateField handles this β the event fires, the guard exits early, and no validation runs until the gate opens.
<!-- Explicit autocomplete attributes reduce autofill-timing surprises -->
<input name="email" type="email" autocomplete="email" ... />
3. Stale closure over $isHydrated in a debounce wrapper
If you debounce handleInput and capture $isHydrated in the debounce closure at call time, the captured value may be false even though the gate has since opened by the time the debounced function actually runs.
// WRONG β $isHydrated captured at call time (before gate opens)
const debouncedValidate = debounce((name: string, value: string) => {
if (!$isHydrated) return; // always false if debounce fires early
}, 300);
Fix: read the store value inside the debounced callback, not at the point of call. With Svelteβs $ auto-subscription, $isHydrated inside a <script> block is always the current value β but only inside the reactive Svelte context. If you extract the debounce to a plain .ts module, use get(isHydrated) from svelte/store instead.
4. beforeNavigate not firing on hard navigation
beforeNavigate only fires for SvelteKitβs client-side router transitions. A full page reload bypasses it. This is fine β a hard reload re-runs the full SSR cycle, so there is no stale state to reset. Do not add a beforeunload listener as a workaround; it causes problems with browser back/forward cache.
5. Design system wrapper components that forward ARIA props
If you use a component library where <Input> wraps a native <input>, confirm the wrapper forwards aria-invalid and aria-describedby directly to the underlying element. Wrappers that cache ARIA props internally may delay propagation, making the gate ineffective for those attributes.
Verification checklist
- Open DevTools console on initial page load β zero βHydration mismatchβ warnings appear
aria-invalidisfalseon all inputs immediately after load before the user types anything- After the first
inputevent,aria-invalidtoggles correctly androle="alert"errors are announced by VoiceOver / NVDA - Simulate 3G in Chrome DevTools β no validation errors flash during page load
- Navigate away and back using SvelteKitβs client router β confirm no stale error state on return
- Run
axe-corepost-hydration β zero violations foraria-liveregions or invalid attribute combinations - In CI, assert
console.warnis never called with the substringHydration mismatchduring form load (Playwright:page.on('console', ...)) - Rapid input typing does not accumulate orphaned validation errors from aborted async requests
FAQ
Why does onMount validation trigger hydration warnings even when the DOM looks correct?
Svelte computes a checksum of the server-rendered HTML before the client mounts. If onMount mutates any attribute β class, data-*, aria-* β synchronously, the checksum comparison is still running when the mutation lands, and Svelte flags a divergence. The visual output might look identical to you, but the internal tree comparison has already failed. Awaiting tick() defers the mutation until after the comparison clears.
Does the hydration gate delay validation noticeably for users?
No. tick() resolves in a single microtask, typically under 2 ms on any modern device. The gate is invisible to users β it only covers the window between the initial HTML parse and the component mount, before the user could realistically have interacted with the form.
How do I test for hydration mismatches in CI?
In Playwright, attach a console event listener before navigating to the page and collect all warnings. After the page load completes, assert that none of the collected messages contain 'Hydration mismatch'. Pair this with an axe-core scan to assert aria-invalid is false on every input at load time β that combination catches both the mismatch and its accessibility side-effect.
Does this pattern work with Svelte 5 runes?
Yes. Replace writable with $state and derived with $derived. Keep the same onMount / tick() fence β the hydration lifecycle has not changed in Svelte 5, only the reactivity primitives. The beforeNavigate call and AbortController pattern carry over unchanged.
Related
- Hydration Sync for SSR Forms β the parent topic covering the full hydration sync approach across frameworks
- Svelte Store Integration for Forms β how to structure writable and derived stores for form state management
- Implementing Async Email Availability Checks β the
AbortControllercancellation pattern used in the validation gate above - Asynchronous Validation Strategies β broader async validation patterns including debounce and retry