Advanced Runes
$inspect: reactive debugging
Section titled “$inspect: reactive debugging”$inspect logs reactive values and re-logs whenever they change — a development-only tool (it becomes a no-op in production builds). Unlike a plain console.log, which fires once, $inspect tracks its arguments and reports every update.
<script> let count = $state(0); let doubled = $derived(count * 2);
$inspect(count, doubled); // logs on every change, dev only</script>You can chain .with(fn) to run custom logic on each change (for example, to console.trace where a change came from).
untrack: read without depending
Section titled “untrack: read without depending”Inside a $derived or $effect, every reactive value you read becomes a dependency. Occasionally you want to read a value without creating that dependency — so the effect doesn’t re-run when it changes. untrack (imported from svelte) does that.
<script> import { untrack } from 'svelte';
let count = $state(0); let config = $state({ verbose: true });
$effect(() => { // re-run when count changes, but NOT when config.verbose changes: const verbose = untrack(() => config.verbose); if (verbose) console.log(count); });</script>The Effect Rune lesson proves this with a running test, including the “control” case that shows what happens without untrack.
$effect.tracking, $effect.root, and $effect.pre
Section titled “$effect.tracking, $effect.root, and $effect.pre”Three more $effect-family runes round out the toolkit for advanced/manual control:
$effect.tracking()returnstrueif the current code is running inside a tracking context (an effect, a derived, or the template) — andfalseotherwise. It’s for building custom reactive abstractions that need to know whether they’re being read reactively.$effect.root()creates an effect scope that is not automatically cleaned up by its parent — you get adestroyfunction back and must call it yourself. This is the escape hatch for effects created outside the normal component lifecycle (a state library, a test, or an imperative integration).$effect.pre()— covered in full, with cleanup-ordering proof, in the Effect Rune lesson. Briefly: it’s the “before the DOM updates” sibling of$effect, and it’s exactly what you reach for when a manually-rooted effect needs to read something ahead of a DOM write.
<script> console.log($effect.tracking()); // false — plain component setup, not tracked
$effect(() => { console.log($effect.tracking()); // true — inside an effect });</script>
<p>in template: {$effect.tracking()}</p> <!-- true -->Proven — $effect.tracking() really does flip between contexts:
import { flushSync } from 'svelte';import { expect, test } from 'vitest';
test('$effect.tracking() is false outside a tracking context, true inside an effect', () => { const results: boolean[] = []; const destroy = $effect.root(() => { results.push($effect.tracking()); // false: plain top-level call, no tracking context $effect(() => { results.push($effect.tracking()); // true: inside an effect }); flushSync(); }); destroy(); expect(results).toEqual([false, true]);});✓ $effect.tracking() is false outside a tracking context, true inside an effect
Test Files 1 passed (1) Tests 1 passed (1)And $effect.root() really does require manual destroy() — nothing tears it down for you:
import { flushSync } from 'svelte';import { expect, test } from 'vitest';
test('$effect.root() creates a scope with manual cleanup — not auto-destroyed', () => { const log: string[] = []; let count = $state(0);
const destroy = $effect.root(() => { $effect(() => { log.push(`run ${count}`); return () => log.push(`cleanup ${count}`); }); });
flushSync(); expect(log).toEqual(['run 0']);
count = 1; flushSync(); expect(log).toEqual(['run 0', 'cleanup 0', 'run 1']);
// Nothing destroys this root automatically — only calling destroy() does. destroy(); expect(log).toEqual(['run 0', 'cleanup 0', 'run 1', 'cleanup 1']);
// After destroy, further state changes do not re-run the effect. count = 2; flushSync(); expect(log).toEqual(['run 0', 'cleanup 0', 'run 1', 'cleanup 1']);});✓ $effect.root() creates a scope with manual cleanup — not auto-destroyed
Test Files 1 passed (1) Tests 1 passed (1)$inspect.trace: tracing WHY something re-ran
Section titled “$inspect.trace: tracing WHY something re-ran”$inspect.trace(label?) is a more targeted debugging tool than $inspect: placed as the first statement inside an effect or derived, it prints which specific reactive reads caused that particular run — useful when a function has many dependencies and you need to know exactly which one changed.
<script> let a = $state(1); let b = $state(2);
$effect(() => { $inspect.trace('sum effect'); console.log(a + b); });</script>Proven — it logs the label when the effect re-runs from a dependency change:
import { flushSync } from 'svelte';import { expect, test, vi } from 'vitest';
test('$inspect.trace() prints which dependency caused a derived/effect to re-run', () => { const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); const destroy = $effect.root(() => { let a = $state(1); let b = $state(2);
$effect(() => { $inspect.trace('sum effect'); void (a + b); // read both — both become dependencies });
flushSync(); spy.mockClear();
a = 10; // only `a` changed flushSync(); }); destroy();
const logged = spy.mock.calls.flat().join(' '); expect(logged).toContain('sum effect'); spy.mockRestore();});✓ $inspect.trace() prints which dependency caused a derived/effect to re-run
Test Files 1 passed (1) Tests 1 passed (1)Like $inspect, $inspect.trace is dev-only — it disappears from production builds.
$host(): the custom element’s host, for real
Section titled “$host(): the custom element’s host, for real”When a component is compiled to a custom element (a native web component) via <svelte:options customElement="...">, there is no parent component to receive callback props or dispatch normal Svelte events to — the component’s only connection to the outside world is the DOM element hosting it. $host() returns that host element, so you can dispatch real CustomEvents on it or call DOM APIs directly.
<svelte:options customElement="my-stepper" />
<script> function dispatch(type) { $host().dispatchEvent(new CustomEvent(type)); }</script>
<button onclick={() => dispatch('decrement')}>decrement</button><button onclick={() => dispatch('increment')}>increment</button>A consumer of <my-stepper> — in any framework, or none — listens like any other DOM element:
document.querySelector('my-stepper').addEventListener('increment', () => { console.log('incremented!');});Proven — compiling with customElement: true really does register a native custom element, and $host() compiles away entirely (there’s no runtime function call left in the output):
import { compile } from 'svelte/compiler';import { expect, test } from 'vitest';
const source = `<svelte:options customElement="my-counter" /><script> let { start = 0 } = $props(); let count = $state(start); function bump() { count += 1; $host().dispatchEvent(new CustomEvent('bump', { detail: count })); }</script><button onclick={bump}>{count}</button>`;
test('compiling with <svelte:options customElement> + $host() registers a native custom element', () => { const { js } = compile(source, { customElement: true, generate: 'client' }); expect(js.code).toContain('customElements.define'); expect(js.code).toContain("'my-counter'"); // $host() compiles away to `this` inside the custom element class — no // runtime function named "$host" survives in the emitted JS. expect(js.code).not.toMatch(/\$host\s*\(/);});✓ compiling with <svelte:options customElement> + $host() registers a native custom element
Test Files 1 passed (1) Tests 1 passed (1)And using $host() outside a custom-element component is a real, immediate compile error:
$ node -e "compile(badSource, { generate: 'client' })"host_invalid_placement`$host()` can only be used inside custom element component instanceshttps://svelte.dev/e/host_invalid_placementClass fields with runes (and the #private gotcha)
Section titled “Class fields with runes (and the #private gotcha)”Runes aren’t limited to let declarations — you can put them directly on class fields, public or private. Svelte does not proxy class instances the way it proxies plain objects/arrays passed to $state; instead, a class field declared with $state or $derived is transformed by the compiler into an accessor backed by a real private field.
<script> class Counter { count = $state(0); #step = $state(1); // private fields can be runes too
get double() { return this.count * 2; } increment() { this.count += this.#step; } setStep(n) { this.#step = n; } }
const counter = new Counter();</script>
<button onclick={() => counter.increment()}>{counter.double}</button>This works exactly like $state/$derived anywhere else — reading counter.double inside an effect or the template tracks it, and calling counter.increment() triggers updates:
import { flushSync } from 'svelte';import { expect, test } from 'vitest';
class Counter { count = $state(0); #step = $state(1);
get double() { return this.count * 2; } increment() { this.count += this.#step; } setStep(n: number) { this.#step = n; }}
test('class fields declared with $state/$derived-style getters are reactive', () => { const log: number[] = []; const destroy = $effect.root(() => { const counter = new Counter(); $effect(() => { log.push(counter.double); }); flushSync(); counter.increment(); flushSync(); counter.setStep(5); counter.increment(); flushSync(); }); destroy(); expect(log).toEqual([0, 2, 12]); // 0*2, 1*2, (1+5)*2});✓ class fields declared with $state/$derived-style getters are reactive
Test Files 1 passed (1) Tests 1 passed (1)The gotcha: because the compiler transforms a $state class field into a get/set accessor pair backed by a private field, the property is not enumerable — it won’t show up in Object.keys, a for...in loop, or JSON.stringify. Code that expects a class instance to behave like a plain data object (serialize it, iterate its keys) will be surprised:
test('gotcha: a public $state class field is a non-enumerable accessor, not a plain property', () => { const counter = new Counter(); expect(Object.keys(counter)).not.toContain('count'); expect(JSON.stringify(counter)).toBe('{}'); // nothing serializes // It still reads/writes normally from the outside: expect(counter.count).toBe(0); counter.count = 3; expect(counter.count).toBe(3);});✓ gotcha: a public $state class field is a non-enumerable accessor, not a plain property
Test Files 1 passed (1) Tests 1 passed (1)If you need a plain, serializable snapshot of a reactive class instance (or any $state value), reach for $state.snapshot(x) — covered in full in the $state Rune lesson.
Reactive state beyond the component
Section titled “Reactive state beyond the component”Runes are not limited to .svelte files. In a .svelte.js or .svelte.ts module, you can use $state and $derived to hold reactive state that any component can import — the foundation for app-wide shared state without stores.
export function createCounter() { let count = $state(0); return { get count() { return count; }, // expose via a getter to preserve reactivity increment() { count++; }, };}<script> import { createCounter } from './counter.svelte.js'; const counter = createCounter();</script>
<button onclick={counter.increment}>{counter.count}</button>flowchart LR mod[".svelte.js module: $state + getters"] --> a["Component A imports it"] mod --> b["Component B imports it"] a --> sync["both stay in sync"] b --> sync
The key detail: export access through a getter (or an object/class), not the bare reassigned value. A primitive exported directly would be copied at import time and lose reactivity; a getter reads the live reactive source each time. If that module-level state is exported directly (not wrapped in a factory function like createCounter above) — a single object shared by every importer for the lifetime of the process — read the server-side rendering warning in the $state Rune lesson before doing that in an app that runs on a server.
Mental Model
Section titled “Mental Model”Everything in this lesson is a manual control for situations the ordinary $state/$derived/$effect trio doesn’t cover on its own: $effect.root/$effect.tracking for building abstractions outside the normal component lifecycle, $inspect/$inspect.trace for observing the dependency graph without changing it, $host() for the one case where a component has no parent at all (a custom element), and class fields for when your reactive state naturally has methods and encapsulation. None of these change how reactivity works — they just give you more places to plug into it.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
$effect.root() for manual scopes | Full control over lifecycle outside components | You own cleanup — forgetting destroy() leaks effects forever |
| Class fields with runes | Encapsulation, methods, familiar OOP shape | Public $state fields are non-enumerable — breaks naive serialization/iteration |
.svelte.js module state | Zero-boilerplate shared state, no store API | A bare exported reactive value is one instance for the whole process — dangerous under SSR |
$host() for custom elements | Framework-agnostic event contract (native CustomEvent) | Only valid inside a customElement component — compile error everywhere else |
Common Misconceptions
Section titled “Common Misconceptions”- “Wrapping
new Foo()in$state(...)makes the whole instance deeply reactive.” It does not — class instances are never proxied. Only fields declared inside the class with$state/$derivedare reactive. - “A class’s reactive fields will show up in
Object.keysorJSON.stringifylike a plain object’s would.” They don’t — proven above. The compiler backs them with private fields and non-enumerable accessors. - “
$effect.root()cleans itself up when the component unmounts, like a normal$effectwould.” It explicitly does not — that’s the whole point of the rune. You must call the returneddestroy()yourself. - “
$host()works in any component, you just have to call it inside a handler.” It’s a compile error (host_invalid_placement) anywhere outside a component compiled with<svelte:options customElement>— proven above.
Reading AI-generated code
Section titled “Reading AI-generated code”When you see a class with runes, or module-level shared state, check for these:
- Does a factory (
createCounter(),createStore()) expose reactive fields through getters, or does it export a bare reassignable primitive that will lose reactivity on import? - Is module-scope
$stateever mutated during server-side rendering? If this app runs on a server, that state is shared across every request, not private per-user. - Does code assume a class instance with
$statefields will serialize withJSON.stringifyor enumerate withObject.keys/spread — when the compiler makes those fields non-enumerable? - Is a value destructured out of a reactive class instance (
const { count } = counter) and then used later, expecting it to stay live? Destructuring copies the current value — it is not a live binding. - Is
$host()used somewhere other than a component compiled with<svelte:options customElement>?
🐛 Spot the bug
class Counter {count = $state(0);increment() { this.count += 1;}}
function Stats({ counter }) {// Pull the current count out for "convenience"...const { count } = counter;$effect(() => { console.log('count changed to', count);});}Show the answer
Destructuring const { count } = counter reads the class field’s current value once and copies it into a plain local variable — it is not a live reference back to the reactive field. The $effect above only ever depends on that local count variable, which is never reassigned after the destructure, so the effect runs exactly once and never again, no matter how many times counter.increment() is called afterwards. Proven in the probe: reading counter.double directly inside the effect (no destructuring) produces [0, 2, 12] across three increments, while destructuring first and reading the copy produces only [0] — the effect never re-runs. The fix is to keep reading through the instance inside the tracked scope — counter.count (or a getter like counter.double) — never destructure a reactive class field before using it inside an effect or derived.