Svelte 5 runes, component patterns, reactivity, and SvelteKit data loading best practices
99
99%
Does it follow best practices?
Impact
99%
1.11xAverage score across 10 eval scenarios
Passed
No known issues
Svelte 5 introduced runes -- a new reactivity system that replaces export let, $:, createEventDispatcher, and slots. This guide covers the correct Svelte 5 patterns and common mistakes.
In Svelte 5, use $props() to declare component props. export let is legacy Svelte 4 syntax.
<script lang="ts">
// WRONG: export let is Svelte 4 syntax
export let name: string;
export let count: number = 0;
</script><script lang="ts">
// RIGHT: Use $props() with destructuring and defaults
let { name, count = 0 }: { name: string; count?: number } = $props();
</script>For components that accept callback props (replacing events):
<script lang="ts">
let { value, onchange }: { value: string; onchange: (val: string) => void } = $props();
</script>For two-way binding with $bindable():
<script lang="ts">
// Allows parent to use bind:value on this component
let { value = $bindable('') }: { value?: string } = $props();
</script>
<input bind:value />In Svelte 5, reactive state must be declared with $state. A plain let is NOT reactive in Svelte 5 runes mode.
<script lang="ts">
// WRONG: plain let is not reactive in Svelte 5 runes mode
let count = 0;
let items = ['a', 'b'];
</script><script lang="ts">
// RIGHT: $state makes values reactive
let count = $state(0);
let items = $state(['a', 'b']);
</script>
<button onclick={() => count++}>Count: {count}</button>$state(value) -- deep reactivity. Use when you mutate individual properties or array items (e.g., todo.done = true, items.push(x)). Wraps the value in a deeply reactive proxy.$state.raw(value) -- shallow/no-proxy reactivity. Use when the value is only ever replaced wholesale (e.g., API responses, large datasets, immutable data). Only triggers updates when you reassign the entire variable. More efficient because it avoids creating a deep proxy.Rule of thumb: If data comes from an API/fetch and you replace the whole array/object each time, use $state.raw(). If you mutate individual items in place, use $state().
<script lang="ts">
// $state() -- deep reactivity for data you MUTATE in place
let todos = $state([{ text: 'Learn runes', done: false }]);
// This works: todos[0].done = true; (triggers update)
// $state.raw() -- for data you only REPLACE wholesale
// ALWAYS use $state.raw for large datasets, API responses, catalog data
let products = $state.raw<Product[]>([]);
let apiResponse = $state.raw<ApiData | null>(null);
async function fetchProducts() {
// Replace the whole array -- $state.raw is perfect for this
products = await fetch('/api/products').then(r => r.json());
}
</script>In Svelte 5, computed/derived values use $derived (for expressions) or $derived.by (for multi-statement logic). The $: syntax is legacy Svelte 4.
<script lang="ts">
// WRONG: $: is Svelte 4 reactive declaration
$: doubled = count * 2;
$: fullName = `${firstName} ${lastName}`;
$: {
// Multi-statement reactive block -- legacy
const filtered = items.filter(i => i.active);
result = filtered.length;
}
</script><script lang="ts">
let count = $state(0);
let firstName = $state('');
let lastName = $state('');
let items = $state<{ active: boolean }[]>([]);
// Simple expression: use $derived
let doubled = $derived(count * 2);
let fullName = $derived(`${firstName} ${lastName}`);
// Multi-statement logic: use $derived.by
let activeCount = $derived.by(() => {
const filtered = items.filter(i => i.active);
return filtered.length;
});
</script>Use $effect for code that should re-run when its reactive dependencies change (logging, DOM manipulation, subscriptions). It runs after the DOM updates.
<script lang="ts">
// WRONG: $: for side effects is Svelte 4
$: console.log('count changed:', count);
$: document.title = `Count: ${count}`;
</script><script lang="ts">
let count = $state(0);
// RIGHT: $effect for side effects
$effect(() => {
document.title = `Count: ${count}`;
});
// For effects that must run BEFORE DOM updates:
$effect.pre(() => {
// Runs before DOM update, useful for scroll position preservation
previousScrollHeight = container.scrollHeight;
});
</script>For debugging, use $inspect() instead of console.log -- it re-runs whenever its argument changes:
<script lang="ts">
let count = $state(0);
// Logs every time count changes (dev mode only, stripped in prod)
$inspect(count);
// Or with a custom handler:
$inspect(count).with((type, value) => {
if (type === 'update') debugger;
});
</script>Svelte 5 uses standard HTML attribute syntax for events. The on: directive is legacy Svelte 4.
<!-- WRONG: on:click directive is Svelte 4 -->
<button on:click={handleClick}>Click</button>
<button on:click|preventDefault={handleSubmit}>Submit</button>
<input on:input={handleInput} /><!-- RIGHT: Standard attribute event handlers -->
<button onclick={handleClick}>Click</button>
<button onclick={(e) => { e.preventDefault(); handleSubmit(e); }}>Submit</button>
<input oninput={handleInput} />Note: Event modifiers like |preventDefault and |stopPropagation do not exist in Svelte 5. Call the methods directly on the event object.
For component callbacks, use prop functions (not createEventDispatcher):
<script lang="ts">
// WRONG: createEventDispatcher is Svelte 4
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher<{ save: string }>();
</script>
<button on:click={() => dispatch('save', value)}>Save</button><script lang="ts">
// RIGHT: Callback props replace dispatched events
let { onsave }: { onsave: (value: string) => void } = $props();
</script>
<button onclick={() => onsave(value)}>Save</button>Svelte 5 uses {#snippet} blocks instead of <slot>. Snippets are typed and more flexible.
<!-- Parent: using slot is Svelte 4 -->
<Card>
<h2 slot="header">Title</h2>
<p>Card body content</p>
</Card>
<!-- Card.svelte with slots -->
<div class="card">
<slot name="header" />
<slot />
</div><!-- Parent: using snippet props -->
<Card>
{#snippet header()}
<h2>Title</h2>
{/snippet}
<p>Card body content</p>
</Card><!-- Card.svelte with children and snippet props -->
<script lang="ts">
import type { Snippet } from 'svelte';
let { header, children }: { header?: Snippet; children: Snippet } = $props();
</script>
<div class="card">
{#if header}
{@render header()}
{/if}
{@render children()}
</div>Key rules:
children prop (type Snippet){@render snippetName()}{#snippet row(item)}Use +page.server.ts for server-side data fetching with proper typing and error handling.
// src/routes/posts/+page.server.ts
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ fetch }) => {
const res = await fetch('/api/posts');
if (!res.ok) throw error(res.status, 'Failed to load posts');
const posts = await res.json();
return { posts };
};<!-- src/routes/posts/+page.svelte (Svelte 5) -->
<script lang="ts">
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
</script>
{#each data.posts as post}
<article>
<h2>{post.title}</h2>
<p>{post.body}</p>
</article>
{/each}Note: In Svelte 5, the page receives data via $props(), NOT export let data.
<!-- src/routes/+error.svelte (Svelte 5) -->
<script lang="ts">
import { page } from '$app/stores';
</script>
<div role="alert">
<h1>{$page.status}</h1>
<p>{$page.error?.message}</p>
</div>Use form actions for mutations. Use use:enhance for progressive enhancement.
// src/routes/contact/+page.server.ts
import { fail, redirect } from '@sveltejs/kit';
import type { Actions } from './$types';
export const actions: Actions = {
default: async ({ request }) => {
const data = await request.formData();
const email = data.get('email')?.toString().trim();
if (!email) {
return fail(400, { email, error: 'Email is required' });
}
await saveContact(email);
throw redirect(303, '/thank-you');
},
};<!-- src/routes/contact/+page.svelte (Svelte 5) -->
<script lang="ts">
import { enhance } from '$app/forms';
import type { ActionData } from './$types';
let { form }: { form: ActionData } = $props();
</script>
<form method="POST" use:enhance>
<label for="email">Email</label>
<input id="email" name="email" value={form?.email ?? ''} aria-required="true" />
{#if form?.error}
<span role="alert">{form.error}</span>
{/if}
<button type="submit">Subscribe</button>
</form>$props() for component props (not export let)$state() for reactive variables (not plain let)$derived() / $derived.by() for computed values (not $:)$effect() for side effects (not $: blocks)onclick attribute syntax (not on:click directive)createEventDispatcher){#snippet} and {@render} for composition (not <slot>)children snippet prop for default contentlang="ts" on all <script> blocksload functions in +page.server.ts with PageServerLoad typedata via $props() in Svelte 5fail() for errors, redirect(303) on successuse:enhance on forms for progressive enhancementrole="alert" on error messages for accessibility