Using Svelte 5 Runes in SvelteKit Islands
Svelte 5 replaced the compiler’s implicit reactivity with explicit runes — $state, $derived, $effect, $props — and that change has direct consequences for how much an island costs to hydrate. When a SvelteKit island comes alive in the browser, hydration wires the server-rendered DOM to the reactive graph those runes describe; the leaner that graph, the cheaper the island. This guide, a companion to SvelteKit Component Islands, shows how to use each rune idiomatically inside an island and, just as importantly, how each one moves the hydration bill.
Prerequisites
What Each Rune Costs at Hydration
The four runes do different amounts of work when an island hydrates. The diagram maps each to what it sets up and when.
Implementation Steps
Step 1 — Receive island props with $props
Goal: Take the server-passed data as the island’s initial state without extra reactive overhead.
<script>
// $props reads the values the server passed when it rendered this island.
// These become the island's starting state; reading them costs almost nothing.
let { initial = 0, label = "Count" } = $props();
</script>
Expected output: The island renders with the server-provided initial and label. Because $props only reads passed values, it adds no measurable hydration cost of its own.
Step 2 — Declare mutable state with $state
Goal: Make the values that change on interaction reactive, while keeping the proxied surface small.
<script>
let { initial = 0, label = "Count" } = $props();
// $state makes `count` reactive. It is a single primitive, so the proxy cost
// at hydration is trivial. Keep $state values as small as the UI genuinely needs.
let count = $state(initial);
</script>
<button onclick={() => count++}>{label}: {count}</button>
For large, read-only data that never mutates, avoid the deep-proxy cost by using $state.raw:
<script>
let { rows } = $props();
// $state.raw stores the array WITHOUT deep-proxying every element. Reassigning
// `table = [...]` is still reactive, but Svelte does not build proxies over
// thousands of rows at hydration — a real saving for large datasets.
let table = $state.raw(rows);
</script>
Expected output: Clicking the button increments count and updates only the bound text node. The $state.raw table renders without the hydration-time proxy pass that a plain $state array would incur.
Step 3 — Compute with $derived, not $effect
Goal: Produce values from state with a lazy memo that adds no post-hydration work.
<script>
let count = $state(0);
// $derived is a lazy, memoized computation. It does NOT run at hydration and
// recomputes only when `count` changes AND the value is actually read.
let isEven = $derived(count % 2 === 0);
let doubled = $derived(count * 2);
</script>
<p>{count} is {isEven ? "even" : "odd"} — doubled: {doubled}</p>
Expected output: isEven and doubled update in lockstep with count, with no extra scheduled work. Compared with computing these in an $effect, hydration stays cheaper and there is no risk of an update loop.
Step 4 — Reserve $effect for genuine side effects
Goal: Use $effect only where you must reach outside the reactive graph — DOM APIs, subscriptions, logging.
<script>
let count = $state(0);
// $effect runs AFTER hydration and re-runs when its dependencies change. This
// is the right tool for a genuine side effect (updating document.title), and
// the WRONG tool for deriving a value — use $derived for that.
$effect(() => {
document.title = `Count: ${count}`;
// Return a cleanup function for subscriptions/timers to avoid leaks.
return () => { document.title = "App"; };
});
</script>
Expected output: The document title tracks count. Because the effect runs after hydration, it is the most expensive of the four runes when overused — one or two purposeful effects per island is a healthy ceiling.
Verification
-
Hydration profile. In DevTools → Performance, record a load under a 4G / CPU 4× profile and locate the island’s hydration task. It should be short; a wide task usually means a large object was placed in plain
$state(deep proxying) or too many$effects are firing after hydration. -
Mark the island boundary. Add a one-off mark to confirm the island hydrated and measure its window:
<script> $effect(() => { performance.mark(`island:hydrated:counter`); }); </script>Read the mark back with a
PerformanceObserver, exactly as in the core measurement workflow. A static component in the same page should produce no such mark. -
Derived vs effect check. Confirm computed values use
$derivedand not$effect. Search the island for$effectand verify each one performs a real side effect (DOM, subscription, network) rather than assigning a value — a value assignment inside an effect is a red flag for both cost and update loops. -
Large-data proxy check. For any island holding a big array or object, verify it uses
$state.raw(or is not reactive at all). Toggling between$stateand$state.rawon a thousands-of-rows dataset should show a measurable difference in the hydration task width.
Troubleshooting
The island's hydration task is unexpectedly long
Root cause: A large object or array is stored in plain $state, so Svelte deep-proxies every element at hydration even though the data never mutates.
Fix: Switch read-only or bulk data to $state.raw, which stores the value without per-element proxies. Reassignment stays reactive, but hydration no longer builds proxies you never use.
let table = $state.raw(rows); // instead of $state(rows)
An `$effect` runs in an infinite loop
Root cause: The effect both reads and writes the same reactive state — for example computing a value and assigning it back to a $state variable — so each run re-triggers itself.
Fix: Replace the value-computing effect with $derived, which memoizes without writing back. Reserve $effect for side effects that do not mutate the state they depend on.
// WRONG — effect writes the state it reads
$effect(() => { doubled = count * 2; });
// RIGHT — derived value, no write-back
let doubled = $derived(count * 2);
Island props are `undefined` after hydration
Root cause: The server passed a non-serialisable value (a function, Date, or class instance) as a prop, so it did not survive the server-client handoff into $props.
Fix: Pass plain serialisable data and reconstruct richer types inside the island. Handle interactions with the island’s own event handlers rather than passing callbacks as props — see cross-boundary prop passing.
State mutations do not update the DOM
Root cause: The value was never wrapped in $state, so mutating it is not reactive — a plain let in a runes component is inert for reactivity.
Fix: Wrap any value that changes and should update the UI in $state. For nested mutation to be reactive, use $state (deep proxy) rather than $state.raw, or reassign the whole value when using $state.raw.
Related
- SvelteKit Component Islands — the parent guide on establishing island boundaries and streaming SSR in SvelteKit.
- Handling Form Submissions Across SvelteKit Islands — coordinating state and actions between islands once runes are in place.
- Cross-Boundary Prop Passing — passing serialisable props into island components so
$propsreceives them intact.
← Back to SvelteKit Component Islands