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.

Three things are written; everything else is computed The values store is written by the reader's input. The touched store is written on blur. The submit count is written on each submit attempt. From those three, a derived store computes every schema issue keyed by field; a second derived store filters those issues down to the ones the reader has earned the right to see, using touched and the submit count; and a third derives whether the form may be submitted at all. Nothing writes the derived stores, which is why the error state cannot disagree with the values — and why a reset is a single assignment. values writable · the reader touched writable · on blur submitCount writable · on submit issues derived · every schema issue visibleErrors derived · what is rendered canSubmit derived · no issues at all Nothing writes the right-hand column, so a reset is one assignment to values plus clearing touched — everything follows. Is it writable, or is it derived? A value the reader types is written. A flag set when they leave a field is written. A count incremented on submit is written. Everything else on this list is computed from those three: whether a field has an error, whether an error should be shown, whether the form may be submitted, how many problems there are, and which field the summary should link to first. If a fourth writable appears, the test is whether anything other than the reader causes it to change. State Written or derived From field values written the reader touched flags written blur submit count written submit attempts per-field errors derived values shown errors, canSubmit, count derived the three above The test for a proposed fourth writable: does anything other than the reader cause it to change? If not, it is derived.

Step-by-Step Walkthrough

  1. Keep the writable set minimal. Values, touched, submit count. If a fourth appears, check whether it is derivable.

  2. Derive issues from values alone. Pure, cheap, and always current.

  3. Derive visibility separately. “Is invalid” and “should be shown” are different questions, and conflating them is what forces a writable errors store.

  4. Never write a derived store. If you find yourself wanting to, the value belongs in the writable set or the derivation is wrong.

  5. Use get sparingly. Reading a store outside a reactive context is fine in an event handler and wrong in a derivation, where it silently drops the dependency.

  6. Reset by assignment. One write to values, one clear of touched, 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.

What a reset touches Assign the initial values. Clear the touched map. Zero the submit count. Nothing else — the issues, the visible errors and the can-submit flag all recompute because their inputs changed. A reset that has to clear an errors store as well is a reset that can forget to, and a form that shows an error for a field that has just been emptied is exactly that bug. What a reset touches values assign the initial object touched clear the map entirely submitCount back to zero everything else recomputes — nothing to clear Three writes and a reset is complete. Add a writable errors store and it becomes four, one of which will be forgotten.

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 get inside 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

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’.