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.

Svelte 5 Runes and Their Hydration Cost Four rows. $props reads server-passed props with negligible ongoing cost. $state builds reactive proxies over its value. $derived registers a lazy memoized computation that does not run until read. $effect schedules a side effect that runs after hydration completes, the most expensive of the four when overused. RUNE → HYDRATION COST (low to high) $props initial island state Reads passed values — negligible ongoing cost $state mutable reactivity Builds reactive proxies over the value deep proxy cost scales with object size — use $state.raw for large read-only data $derived computed value Registers a lazy memo — runs only when read cheaper than $effect for computing values $effect side effects Schedules a side effect that RUNS after hydration most expensive when overused — reserve for DOM / external effects only

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

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

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

  3. Derived vs effect check. Confirm computed values use $derived and not $effect. Search the island for $effect and 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.

  4. 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 $state and $state.raw on 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.


← Back to SvelteKit Component Islands