The exact problem: a Svelte form keeps errors in a writable store and updates it from three different event handlers, so the error state and the values disagree after a reset — and nobody can say which handler was responsible.
Context and Prerequisites
The store shapes are covered in Svelte store integration for forms. The principle here is narrower and worth stating plainly: validation state is derived, so it belongs in a derived store, not a writable one. Anything computable from the values plus the touched flags should never be stored separately, because a stored copy of a derived value is a copy that can be wrong.
Core Pattern
import { writable, derived, get } from 'svelte/store';
import { z } from 'zod';
const schema = z.object({
email: z.string().email('Enter an address we can reach you at'),
password: z.string().min(12, 'Use 12 characters or more'),
});
// Written by the reader. These two are the only writable stores in the form.
export const values = writable({ email: '', password: '' });
export const touched = writable<Record<string, boolean>>({});
export const submitCount = writable(0);
/** Every issue the schema reports, keyed by field. Pure, and always current. */
const issues = derived(values, ($values) => {
const r = schema.safeParse($values);
if (r.success) return {} as Record<string, string>;
return Object.fromEntries(
r.error.issues.map((i) => [String(i.path[0]), i.message]),
);
});
/**
* What the reader is actually shown: an issue, but only for fields that have
* earned a message. Separating "is invalid" from "should be told" is what lets
* the form be quiet while composing without a second copy of the errors.
*/
export const visibleErrors = derived(
[issues, touched, submitCount],
([$issues, $touched, $submitCount]) =>
Object.fromEntries(
Object.entries($issues).filter(([field]) => $submitCount > 0 || $touched[field]),
),
);
export const canSubmit = derived(issues, ($issues) => Object.keys($issues).length === 0);
Three writable stores and three derived ones. Nothing writes errors, so nothing can write it wrongly, and a reset is one assignment to values plus clearing touched — every downstream value follows.
Step-by-Step Walkthrough
-
Keep the writable set minimal. Values, touched, submit count. If a fourth appears, check whether it is derivable.
-
Derive issues from values alone. Pure, cheap, and always current.
-
Derive visibility separately. “Is invalid” and “should be shown” are different questions, and conflating them is what forces a writable errors store.
-
Never write a derived store. If you find yourself wanting to, the value belongs in the writable set or the derivation is wrong.
-
Use
getsparingly. Reading a store outside a reactive context is fine in an event handler and wrong in a derivation, where it silently drops the dependency. -
Reset by assignment. One write to
values, one clear oftouched, and the whole graph follows.
Failure Modes and Edge Cases
1. A derived store that never updates
Almost always a dependency read through get instead of being listed. derived tracks only what is in its dependency array.
2. Re-parsing the whole schema per keystroke
Fine up to a few dozen fields; beyond that, derive per field so only the edited field’s rules re-run.
3. Async validation in a derived store
derived supports an asynchronous set callback, but a remote check does not belong there — it needs cancellation, debouncing and a stale check. Keep it in a writable populated by the validation queue, and merge the two in a further derivation.
4. Subscribing in a plain module
The $ prefix only exists in components. A helper module must call subscribe and own the unsubscribe, or use get for a single read.
5. Store values captured in a closure
An event handler closing over $values from render time sees a stale snapshot. Read through get inside the handler.
Verification Checklist
Common Pitfalls
- A writable errors store. It is a copy of something computable, so every handler that changes a value has to remember to update it, and one of them will not.
- Reading a dependency with
getinside a derivation. The dependency is not tracked, so the derived store silently stops updating — with no error and no warning. - Conflating invalid with shown. They are different questions, and merging them is what forces a writable store to hold the answer to the second.
- Async results in a derivation. A remote check needs debouncing, cancellation and a staleness check, none of which a derived store can express. Give it its own writable and merge afterwards.
- Subscribing in a module without unsubscribing. The
$prefix only exists in components; a helper module owns the returned function, and forgetting it keeps the closure alive for the page’s lifetime.
Related
- Svelte Store Integration for Forms — the store shapes
- Svelte 5 Runes Migration for Form Stores — the same graph in runes
- Queueing Async Validators in Order — where the async results come from
← Svelte Store Integration for Forms
Frequently Asked Questions
Why not keep errors in a writable store?
Because it is a copy of something computable, and copies go stale. Every handler that changes a value has to remember to update the errors too, a reset has to clear both, and the moment one path forgets, the form shows an error for a value that no longer exists. A derived store cannot be forgotten: it recomputes whenever its inputs change, which is exactly the guarantee you want from validation state.
How do async validation results fit into a derived graph?
They do not belong in a derivation, because they need debouncing, cancellation and a staleness check that a derived store has no way to express. Put the async results in their own writable, populated by the validation queue, and add a final derivation that merges the synchronous issues with the async ones. That keeps the pure part pure and confines the messy part to one store.
Is per-field derivation worth the extra wiring?
Not until you can measure it. Re-parsing a schema of a few dozen fields on each keystroke is microseconds, and the single derivation is much easier to reason about. Once the form is large enough that the parse shows up in a profile, derive per field so only the edited field’s rules re-run — and keep the whole-form derivation for the submit check, so there is still one place that answers ‘is this form valid’.