CtrlK
BlogDocsLog inGet started
Tessl Logo

tessl-labs/svelte-best-practices

Svelte 5 runes, component patterns, reactivity, and SvelteKit data loading best practices

99

1.11x
Quality

99%

Does it follow best practices?

Impact

99%

1.11x

Average score across 10 eval scenarios

SecuritybySnyk

Passed

No known issues

Overview
Quality
Evals
Security
Files
name:
svelte-best-practices
description:
Svelte 5 runes, SvelteKit patterns, component design, reactivity, and data loading. Use when building or reviewing Svelte/SvelteKit apps, especially with Svelte 5 runes ($state, $derived, $effect, $props), snippets, event handling, SvelteKit load functions, and form actions.
keywords:
svelte, svelte 5, sveltekit, svelte runes, $state, $derived, $effect, $props, $bindable, $inspect, svelte snippet, svelte onclick, sveltekit load, sveltekit actions, svelte reactivity, svelte best practices
license:
MIT

Svelte 5 / SvelteKit Best Practices

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.


1. Component Props: $props() replaces export let

In Svelte 5, use $props() to declare component props. export let is legacy Svelte 4 syntax.

WRONG (Svelte 4 legacy -- do not use in Svelte 5)

<script lang="ts">
  // WRONG: export let is Svelte 4 syntax
  export let name: string;
  export let count: number = 0;
</script>

RIGHT (Svelte 5)

<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 />

2. Reactive State: $state replaces let declarations

In Svelte 5, reactive state must be declared with $state. A plain let is NOT reactive in Svelte 5 runes mode.

WRONG

<script lang="ts">
  // WRONG: plain let is not reactive in Svelte 5 runes mode
  let count = 0;
  let items = ['a', 'b'];
</script>

RIGHT

<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 vs $state.raw -- IMPORTANT DISTINCTION

  • $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>

3. Derived Values: $derived replaces $: reactive statements

In Svelte 5, computed/derived values use $derived (for expressions) or $derived.by (for multi-statement logic). The $: syntax is legacy Svelte 4.

WRONG (Svelte 4 legacy)

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

RIGHT (Svelte 5)

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

4. Side Effects: $effect replaces $: side-effect blocks

Use $effect for code that should re-run when its reactive dependencies change (logging, DOM manipulation, subscriptions). It runs after the DOM updates.

WRONG (Svelte 4 legacy)

<script lang="ts">
  // WRONG: $: for side effects is Svelte 4
  $: console.log('count changed:', count);
  $: document.title = `Count: ${count}`;
</script>

RIGHT (Svelte 5)

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

5. Event Handling: onclick replaces on:click

Svelte 5 uses standard HTML attribute syntax for events. The on: directive is legacy Svelte 4.

WRONG (Svelte 4 legacy)

<!-- 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 (Svelte 5)

<!-- 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):

WRONG (Svelte 4 legacy)

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

RIGHT (Svelte 5)

<script lang="ts">
  // RIGHT: Callback props replace dispatched events
  let { onsave }: { onsave: (value: string) => void } = $props();
</script>
<button onclick={() => onsave(value)}>Save</button>

6. Snippets Replace Slots for Component Composition

Svelte 5 uses {#snippet} blocks instead of <slot>. Snippets are typed and more flexible.

WRONG (Svelte 4 legacy)

<!-- 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>

RIGHT (Svelte 5)

<!-- 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:

  • Default content uses the special children prop (type Snippet)
  • Named slots become snippet props
  • Render snippets with {@render snippetName()}
  • Snippets can accept parameters: {#snippet row(item)}

7. SvelteKit Data Loading

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.

Error pages

<!-- 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>

8. SvelteKit Form Actions

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>

References


Checklist

  • Use $props() for component props (not export let)
  • Use $state() for reactive variables (not plain let)
  • Use $derived() / $derived.by() for computed values (not $:)
  • Use $effect() for side effects (not $: blocks)
  • Use onclick attribute syntax (not on:click directive)
  • Use callback props for component events (not createEventDispatcher)
  • Use {#snippet} and {@render} for composition (not <slot>)
  • Use children snippet prop for default content
  • lang="ts" on all <script> blocks
  • SvelteKit load functions in +page.server.ts with PageServerLoad type
  • Page components receive data via $props() in Svelte 5
  • Form actions with fail() for errors, redirect(303) on success
  • use:enhance on forms for progressive enhancement
  • role="alert" on error messages for accessibility

Verifiers

  • svelte5-runes -- Svelte 5 runes: $state, $derived, $effect, $props
  • svelte5-component-patterns -- Svelte 5 events, snippets, composition
  • sveltekit-patterns -- SvelteKit load functions, form actions, error handling
Workspace
tessl-labs
Visibility
Public
Created
Last updated
Publish Source
CLI
Badge
tessl-labs/svelte-best-practices badge