You have a form model built on writable() and derived() stores, and moving to Svelte 5 means translating that store graph into $state, $derived, and $effect without silently losing reactivity on nested field mutations or leaking subscriptions.

This walkthrough assumes you already have a working store-based adapter of the kind described in Svelte store integration for forms, and it focuses narrowly on the mechanical and semantic differences you hit during migration. It is not an introduction to runes; it is a field guide for the three bugs that bite when you convert a real form.


Context and prerequisites

The classic pattern is a writable holding the values object, one or more derived stores for isDirty and errors, and component code that reads them through the $store auto-subscription. Runes change the ownership model: reactivity is now a property of a variable declared with $state, not of a store object you pass around. That single shift is the source of every migration gotcha below, so keep it in mind — you are converting transferable subscribable objects into reactive variables scoped to a module or component.

If your form store also drives validation, review how the validation lifecycle expects to be triggered before you rewire the effects, because the flush timing of $effect differs from a store subscription callback.


The equivalence map, as one focused module

Here is a complete before/after of a form store expressed as a shareable .svelte.ts module. Read it top to bottom — the inline comments call out every non-obvious line.

// form-store.svelte.ts
// The .svelte.ts extension is REQUIRED: runes are only compiled in
// .svelte, .svelte.ts, and .svelte.js files. A plain .ts file will
// throw "$state is not defined" at build time.

export interface LoginForm {
  email: string;
  password: string;
  remember: boolean;
}

export function createLoginForm(initial: LoginForm) {
  // BEFORE: const values = writable({ ...initial });
  // AFTER: $state deeply proxies this object. Every property read inside a
  // $derived or $effect (or template) is tracked; every assignment notifies.
  let values = $state<LoginForm>({ ...initial });

  // The pristine baseline is intentionally NOT a rune. It is a plain snapshot
  // we compare against; making it reactive would create a self-referential
  // dependency inside the isDirty derivation below.
  let baseline: LoginForm = { ...initial };

  // BEFORE: const isDirty = derived(values, $v => !shallowEqual($v, baseline));
  // AFTER: $derived re-computes lazily whenever any tracked property of
  // `values` that it reads changes. No manual subscription, no store object.
  const isDirty = $derived(
    values.email !== baseline.email ||
    values.password !== baseline.password ||
    values.remember !== baseline.remember
  );

  // $derived.by is the multi-statement form — use it when the computation
  // needs locals or branching rather than a single expression.
  const errors = $derived.by(() => {
    const e: Partial<Record<keyof LoginForm, string>> = {};
    if (!values.email.includes("@")) e.email = "Enter a valid email";
    if (values.password.length < 8) e.password = "Min 8 characters";
    return e;
  });

  const isValid = $derived(Object.keys(errors).length === 0);

  // Setter functions are the public write surface. Exporting these — rather
  // than the raw `values` binding — is what keeps reactivity intact across
  // module boundaries (see the cross-component section below).
  function update<K extends keyof LoginForm>(key: K, value: LoginForm[K]) {
    // Mutating a property of the proxied object keeps the proxy in the graph.
    // Do NOT do `values = structuredClone(values)` with a plain object — that
    // swaps in an unproxied value and freezes reactivity.
    values[key] = value;
  }

  function hydrate(data: Partial<LoginForm>) {
    // Advance both the live model and the baseline so isDirty stays false.
    Object.assign(values, data);
    baseline = { ...$state.snapshot(values) };
    // $state.snapshot returns a plain, non-proxied deep copy — the correct
    // way to read a rune's value for storage, structured-clone, or an API body.
  }

  function reset() {
    Object.assign(values, baseline);
  }

  // Return an object of GETTERS. A getter re-reads the reactive source on each
  // access, so importers always see current values. Returning `{ values }`
  // instead would capture a one-time snapshot and break downstream reactivity.
  return {
    get values() { return values; },
    get isDirty() { return isDirty; },
    get errors() { return errors; },
    get isValid() { return isValid; },
    update,
    hydrate,
    reset,
  };
}

Step-by-step walkthrough

  1. Replace the values writable with a single $state object. Move the initial object into $state<T>({ ...initial }). Keep the spread so the caller’s object is not aliased into the proxy.

  2. Convert each derived store into $derived (single expression) or $derived.by (block). Drop the explicit dependency argument — runes track reads automatically. Anything you read inside the expression becomes a dependency; anything you do not read is not tracked, which is exactly why the baseline snapshot must be read for isDirty to update.

  3. Move the whole thing into a .svelte.ts module and export getters plus setter functions. This is where cross-component sharing lives; do it now rather than retrofitting later.

  4. Replace store.subscribe(...) side-effects with $effect. Any code that previously ran inside a subscription callback — syncing to localStorage, firing analytics, pushing to a validation queue — moves into $effect, and its teardown moves into the returned cleanup function.

  5. Delete the $store auto-subscription syntax in components. Where a .svelte file wrote {$errors.email}, it now writes {form.errors.email} against the object returned by createLoginForm. There is no leading $; the getter is already reactive.


The migration is mostly mechanical, and holding the equivalences in one picture keeps it that way:

Store idiom to rune idiom, with the differences that are not cosmetic A writable store becomes state, and the difference is that state is deeply reactive while a writable is replaced wholesale, so mutating a nested property now notifies where before it did not. A derived store becomes derived, and dependencies are now tracked automatically rather than declared, which removes the wrong-dependency-list failure entirely. The dollar prefix becomes a plain property read, which works in modules as well as components. Subscribe becomes an effect, whose cleanup is returned rather than being a separate unsubscribe you hold. Get becomes a plain read, with the caveat that reading inside an effect creates a dependency where get did not. Stores Runes The difference that is not syntax writable(v) $state(v) deeply reactive — nested mutation now notifies derived([a,b], fn) $derived(fn) deps tracked, not declared — one bug class gone $values in markup values works in modules too, not only components store.subscribe(fn) $effect(fn) cleanup is returned from the effect body get(store) values inside an effect this creates a dependency

Failure modes and fixes

1. Nested mutation on a detached snapshot loses reactivity

The single most common migration bug. You read the model out for an API call, clone it, and later assign the clone back:

// BROKEN: JSON round-trip produces a plain object; assigning it replaces
// the proxy with an inert value, so subsequent field edits stop updating $derived.
values = JSON.parse(JSON.stringify(await res.json()));

Fix by mutating the existing proxy instead of overwriting it, or by reassigning through a path that keeps $state semantics:

// CORRECT: Object.assign mutates the existing proxied object in place.
Object.assign(values, await res.json());

2. Exporting a let from .svelte.ts breaks reactivity for importers

// BROKEN: importers capture the value at import time, not the live binding.
export let isDirty = $derived(/* ... */);

An imported let is a snapshot; reassignment inside the module is invisible to the importer. Always export a getter or a function:

// CORRECT: the getter re-reads the reactive source on every access.
export const form = { get isDirty() { return isDirty; } };

3. $effect fires more often than the old subscription

A writable subscription fired once per .set(). $effect re-runs whenever any tracked read changes, batched per microtask. If you touch values.email and values.password in the same tick, the effect runs once — but if your effect reads the whole values object, editing any field re-runs it. Scope the reads:

$effect(() => {
  // Reading only `values.email` narrows the dependency to that one field.
  localStorage.setItem("draft-email", values.email);
});

4. Missing $effect cleanup leaks timers and listeners

Subscriptions returned an unsubscribe function; runes use a returned cleanup callback with identical intent.

$effect(() => {
  const id = setInterval(() => autosave($state.snapshot(values)), 5000);
  // The returned function runs before each re-run AND on component destroy.
  // Omitting it leaks one interval per effect re-run — a classic runaway.
  return () => clearInterval(id);
});

5. $state.snapshot forgotten when serializing

Passing a proxied $state object directly to structuredClone, postMessage, or some third-party libraries throws DataCloneError or silently serializes proxy internals. Always unwrap with $state.snapshot(values) before crossing a boundary that expects a plain object, exactly as the hydrate and autosave code above does.


That last row is the one that bites during a migration, because the old code deliberately used get to read without subscribing:

get() did not subscribe; a plain read does Before: an effect subscribed to values with the dollar prefix and read the baseline with get, which deliberately did not create a subscription, so the effect ran once per value change. After a naive migration: the same effect reads both as plain properties, which subscribes to both, so it now also runs whenever the baseline is replaced — and syncBaseline replaces it after every save, re-running validation for no reason and potentially announcing results the reader did not ask for. The fix is to read the baseline through untrack, which is the rune equivalent of the old get. before — one dependency, on purpose reads $values → subscribes reads get(baseline) → does not runs when values change. Only then. after a naive migration — two reads values → subscribes reads baseline → also subscribes also runs after every syncBaseline The fix: untrack(() => baseline) is the rune spelling of the old get() Read it, do not depend on it — the same intent the original code expressed by choosing get over the $ prefix. Symptom to look for: validation re-running, and errors re-announcing, immediately after a successful save.

Verification checklist


Migrating incrementally rather than in one commit

Runes and stores interoperate, which means the migration does not have to be atomic. Ordering it by dependency direction keeps every intermediate commit shippable.

Four shippable stages, in dependency order Stage one: convert the leaf derived stores — the dirty map, validity, can-submit — since nothing depends on them except the view, and expose each through a small readable bridge so existing consumers keep working. Stage two: convert the writable values store to state, keeping the same bridge so components are untouched. Stage three: convert the components, replacing the dollar prefix with plain reads and deleting their bridges one by one. Stage four: delete the remaining bridges and the compatibility helpers. Every stage compiles and ships on its own, so the migration can pause indefinitely at any point. 1 · leaf deriveds dirty, validity, canSubmit bridge kept 2 · the values store writable becomes $state components untouched 3 · the components $ prefix becomes a plain read one file at a time 4 · bridges delete them and the helpers migration done Why leaves first A derived store has no dependants except the view, so converting one cannot break anything upstream. Converting the values store first would change what every derived reads, and the migration stops being reviewable in small pieces. Each stage compiles and ships on its own, so the work can pause indefinitely without leaving a half-migrated form.

Frequently Asked Questions

Do I have to migrate every writable store to runes at once?

No. Runes and the classic store contract interoperate. A $state object can be wrapped to satisfy the store contract, and an existing writable store is still readable with the $store syntax inside a .svelte file. Migrate the form model first, keep leaf stores until you have time, and avoid mixing both as the source of truth for the same field.

Why does mutating a nested field not trigger my $derived?

$state proxies objects and arrays deeply, but only values reachable from the rune at read time are tracked. If you replaced the whole object with a plain (non-proxied) snapshot — for example an object returned by structuredClone or JSON.parse — later mutations bypass the proxy. Assign new data back into the existing $state fields, or reassign the $state variable itself, so the proxy stays in the reactivity graph.

How do I share a runes-based form store across components?

Put the $state and $derived in a .svelte.ts (or .svelte.js) module and export functions or getter objects — not the raw variable. Exporting a reassignable let breaks reactivity across the module boundary because importers capture the value, not the binding. Return an object with getters, or expose setter functions that mutate the module-scoped $state.

Does $effect replace onDestroy for cleanup?

For reactive side-effects, yes. The function you return from $effect runs before the effect re-runs and once more when the component unmounts, so it covers both dependency-change teardown and final cleanup. Keep onDestroy only for cleanup that is unrelated to reactive dependencies, such as tearing down a manually created third-party widget.


Related

Svelte Store Integration for Forms