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
-
Replace the values
writablewith a single$stateobject. Move the initial object into$state<T>({ ...initial }). Keep the spread so the callerโs object is not aliased into the proxy. -
Convert each
derivedstore 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 thebaselinesnapshot must be read forisDirtyto update. -
Move the whole thing into a
.svelte.tsmodule and export getters plus setter functions. This is where cross-component sharing lives; do it now rather than retrofitting later. -
Replace
store.subscribe(...)side-effects with$effect. Any code that previously ran inside a subscription callback โ syncing tolocalStorage, firing analytics, pushing to a validation queue โ moves into$effect, and its teardown moves into the returned cleanup function. -
Delete the
$storeauto-subscription syntax in components. Where a.sveltefile wrote{$errors.email}, it now writes{form.errors.email}against the object returned bycreateLoginForm. There is no leading$; the getter is already reactive.
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.
Verification checklist
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.