Svelte 5 Best Practices

sveltesvelte5runesbest-practices

Svelte 5 Best Practices

Overview

Svelte 5 introduces Runes — a universal, fine-grained reactivity system that replaces the Svelte 4 compiler-driven reactivity. This page documents production-ready patterns for Svelte 5 (v5.0+).

Core Runes

$state — Reactive State

<script>
  // Primitive — fine-grained
  let count = $state(0);
  
  // Object — deeply reactive (proxied)
  let user = $state({ name: 'Ada', age: 36 });
  
  // Array — deeply reactive
  let items = $state(['a', 'b', 'c']);
  
  // Raw — no proxy, only reassignment triggers updates
  let rawData = $state.raw(largeApiResponse);
</script>

Key rules:

  • Only use $state for values that should trigger reactive updates
  • Objects/arrays are deeply reactive — mutation triggers updates (with proxy overhead)
  • Use $state.raw for large objects only ever reassigned, never mutated
  • $state in .svelte.js/.ts modules creates shared state across components

$derived — Computed Values

<script>
  let count = $state(0);
  
  // Simple derivation — expression (preferred)
  let doubled = $derived(count * 2);
  
  // Complex derivation — function
  let expensive = $derived.by(() => {
    return heavyComputation(count);
  });
  
  // Derived objects/arrays — NOT deeply reactive by default
  let summary = $derived.by(() => ({
    total: items.length,
    active: items.filter(i => i.active).length
  }));
</script>

Rules:

  • Use $derived (expression) over $derived.by (function) when possible
  • $derived.by for complex logic, async, or when you need $state inside
  • Derived values are writable — you can assign to them, but they re-evaluate when dependencies change
  • Derived objects/arrays are returned as-is (not proxied)

$effect — Side Effects

<script>
  let count = $state(0);
  
  // Avoid: syncing state to external libs
  $effect(() => {
    d3.select('#chart').datum(data).call(chart);
  });
  
  // Prefer: {@attach} for DOM libs
  // <div {@attach}={d3Chart} />
  
  // Avoid: running code on user interaction
  $effect(() => {
    if (count > 10) analytics.track('milestone');
  });
  
  // Prefer: event handler
  function increment() {
    count++;
    if (count > 10) analytics.track('milestone');
  }
  
  // Debugging: trace reactivity
  $effect(() => {
    $inspect.trace('count-effect');
    console.log(count);
  });
</script>

Anti-patterns to avoid:

  • Updating state inside effects (causes infinite loops)
  • Using effects for event listeners (use <svelte:window onkeydown={...}>)
  • Wrapping effects in if (browser) — effects don’t run on server

$props — Component Props

<script>
  // Destructure with defaults
  let { 
    title = 'Default',
    count = 0,
    onClick
  } = $props();
  
  // Props are reactive — use $derived for computed values
  let style = $derived(count > 10 ? 'danger' : 'normal');
  
  // Bindable props
  let { bind:value = '' } = $props();
</script>

Rules:

  • Treat props as mutable — they can change from parent
  • Always use $derived for values computed from props
  • Use bind: on parent for two-way binding: <Child bind:value={val} />

$inspect — Debugging

<script>
  // Trace what triggered an effect/derived
  $effect(() => {
    $inspect.trace('user-sync');
    syncToBackend(user);
  });
  
  // Log with label
  $inspect('user-change', user);
</script>

Snippets — Reusable Markup

{#snippet button(label, variant='primary')}
  <button class="btn btn-{variant}">{label}</button>
{/snippet}

{#snippet card(title, children)}
  <article class="card">
    <h3>{title}</h3>
    {@render children()}
  </article>
{/snippet}

<!-- Usage -->
{@render button('Save', 'success')}
{@render card('Title', () => <p>Content</p>)}

Key points:

  • Snippets are declared in the template (not in <script>)
  • Pass data via parameters, render with {@render ...}
  • Can be passed as props: <Component snippet={mySnippet} />
  • Replace slots from Svelte 4 — more flexible, type-safe

Event Handlers

<!-- Direct handler -->
<button onclick={handleClick}>Click</button>

<!-- Shorthand -->
<button {onclick}>Click</button>

<!-- Spread (for wrapper components) -->
<button {...props}>Click</button>

<!-- Window/document events -->
<svelte:window onkeydown={handleKey} />
<svelte:document onvisibilitychange={handleVisibility} />

Never use onMount or $effect for window/document listeners.

{@attach} — External Library Integration

<script>
  import * as d3 from 'd3';
  
  let chart = $state(null);
  
  function d3Chart(element) {
    const sel = d3.select(element);
    // ... setup chart
    return () => { /* cleanup */ };
  }
</script>

<div {@attach}={d3Chart} />

Cleanup function runs when element is destroyed.

{@const} — Template Constants

{#each items as item}
  {@const index = items.indexOf(item)}
  {@const isEven = index % 2 === 0}
  <div class={isEven ? 'even' : 'odd'}>
    {item.name} (#{index})
  </div>
{/each}

Performance Patterns

Use $state.raw for Large Data

<script>
  // API response — never mutated, only replaced
  let posts = $state.raw([]);
  
  async function load() {
    posts = await fetchPosts(); // reassignment triggers update
  }
</script>

Avoid Unnecessary Reactivity

<script>
  // NOT reactive — plain variable
  const apiBase = 'https://api.example.com';
  
  // Only reactive if used in template/effect/derived
  let count = $state(0);
</script>

Memoize Expensive Derivations

<script>
  let items = $state([]);
  
  // $derived.by caches until dependencies change
  let sorted = $derived.by(() => 
    [...items].sort((a, b) => a.name.localeCompare(b.name))
  );
</script>

TypeScript

<script lang="ts">
  interface User { name: string; age: number; }
  
  let user = $state<User>({ name: '', age: 0 });
  
  let { name = 'Guest' }: { name?: string } = $props();
  
  // Snippet types
  let { renderRow }: { renderRow: Snippet<[User]> } = $props();
</script>

Migration from Svelte 4

Svelte 4Svelte 5
let count = 0;let count = $state(0);
$: doubled = count * 2;let doubled = $derived(count * 2);
export let title;let { title } = $props();
onMount(() => {...})$effect(() => {...}) (runs on mount)
<slot>{@render children()} via snippet
$$props / $$restPropsUse $props() rest: let { ...rest } = $props()
on:clickonclick (no on: prefix)
createEventDispatcherCallback props: let { onClick } = $props()
setContext / getContextStill works, but prefer shared $state modules

Evolution Notes

Content last updated: 2026-06-05 Next review: 2026-06-12