svelte-5
Svelte 5 Patterns and Gotchas
Svelte 5 introduces runes ($state, $derived, $effect) as the primary reactivity system. This skill documents patterns and solutions for common issues.
Quick Reference
| Scenario | Use This | Not This |
|---|---|---|
| Component state | let x = $state(value) |
let x = writable(value) |
| Derived values | let y = $derived(x * 2) |
$: y = x * 2 |
| Side effects | $effect(() => { ... }) |
$: { ... } |
| Shared state | Context API or .svelte.ts modules |
Module-level writable stores |
| Client-only rendering | export const ssr = false in +page.ts |
{#if browser} wrappers |
SSR + Hydration Issues
Problem: Conditional Blocks Don't Re-render After Hydration
Symptoms:
- State updates correctly (confirmed via console.log)
- But
{#if condition}blocks don't show/hide - Works in dev, breaks in production build with adapter-static
Root Cause:
With adapter-static, pages are pre-rendered with initial state. After hydration, writable stores from svelte/store may not properly reconnect their reactive subscriptions.
Solutions (in order of preference):
1. Disable SSR for Interactive Pages (Simplest)
If your page is purely client-side interactive with no SEO requirements:
// +page.ts
export const ssr = false;
This makes the page client-only, eliminating all hydration issues.
2. Use $state Runes with Mounted Guard
If you need SSR for the page shell but have client-only interactive sections:
<script lang="ts">
import { onMount } from 'svelte';
let detailOpen = $state(false);
let selectedItem = $state<Item | null>(null);
let mounted = $state(false);
onMount(() => {
mounted = true;
});
</script>
{#if mounted && detailOpen && selectedItem}
<!-- Renders only after hydration -->
{/if}
3. Context API for Shared State
For state shared across components in SSR context:
<!-- +layout.svelte -->
<script lang="ts">
import { setContext } from 'svelte';
const panelState = $state({ open: false, item: null });
setContext('panel', panelState);
</script>
<!-- +page.svelte -->
<script lang="ts">
import { getContext } from 'svelte';
const panel = getContext('panel');
</script>
{#if panel.open && panel.item}
<!-- Works because context creates fresh state per request -->
{/if}
Migrating from Stores to Runes
Before (Svelte 4 / Legacy)
<script>
import { writable } from 'svelte/store';
const count = writable(0);
const doubled = derived(count, $c => $c * 2);
function increment() {
count.update(n => n + 1);
}
</script>
<p>{$count} doubled is {$doubled}</p>
After (Svelte 5)
<script>
let count = $state(0);
let doubled = $derived(count * 2);
function increment() {
count++;
}
</script>
<p>{count} doubled is {doubled}</p>
Key Changes:
writable()→$state()derived()→$derived()$storeName→stateName(no $ prefix needed).set()/.update()→ direct assignment
State in .svelte.ts Modules
For shared reactive state across components:
// lib/stores/panel.svelte.ts
import type { Item } from '$lib/types';
let _open = $state(false);
let _item = $state<Item | null>(null);
export const panelState = {
get open() { return _open; },
set open(v: boolean) { _open = v; },
get item() { return _item; },
set item(v: Item | null) { _item = v; },
close() {
_open = false;
_item = null;
}
};
Usage:
<script>
import { panelState } from '$lib/stores/panel.svelte';
</script>
{#if panelState.open && panelState.item}
<Panel item={panelState.item} />
{/if}
$effect Gotchas
Effects Don't Run During SSR
<script>
$effect(() => {
// This only runs on client, never during SSR
console.log('This won\'t appear in server logs');
});
</script>
Cleanup Pattern
<script>
$effect(() => {
const subscription = subscribe();
return () => {
subscription.unsubscribe();
};
});
</script>
Avoid Infinite Loops
<script>
let count = $state(0);
// BAD - infinite loop
$effect(() => {
count = count + 1;
});
// GOOD - explicit dependencies
$effect(() => {
console.log('Count changed:', count);
});
</script>
bits-ui Component Patterns
Migrating let:builder to Child Snippets
In Svelte 4, bits-ui components used let:builder to expose builder props for composition:
<!-- Svelte 4 - BROKEN in Svelte 5 -->
<Tooltip.Trigger asChild let:builder>
<Button builders={[builder]} disabled={true}>
Hover me
</Button>
</Tooltip.Trigger>
In Svelte 5, this pattern causes invalid_default_snippet errors because let: directives conflict with default snippet rendering.
Fix: Use the child snippet pattern:
<!-- Svelte 5 - Correct -->
<Tooltip.Trigger>
{#snippet child({ props })}
<Button {...props} disabled={true}>
Hover me
</Button>
{/snippet}
</Tooltip.Trigger>
Key differences:
- Remove
asChildattribute - Remove
let:builderdirective - Use
{#snippet child({ props })}instead - Spread
{...props}onto the child element instead ofbuilders={[builder]}
This applies to all bits-ui trigger components:
Tooltip.TriggerDialog.TriggerPopover.TriggerDropdownMenu.Trigger- etc.
Why This Breaks
The bits-ui trigger components support two rendering modes:
- Default children:
{@render children?.()}- renders child content directly - Child snippet:
{@render child({ props })}- passes builder props to child
Using let:builder expects mode 2 but the component tries to render mode 1, causing Svelte 5 to throw invalid_default_snippet.
URL State Management
Problem: replaceState/pushState Don't Update $page.url
Symptoms:
- Detail panels or modals reopen immediately after being dismissed
- URL bar shows updated params but
$page.url.searchParamsstill has old values $effectwatching$page.urldoesn't fire after URL change
Root Cause:
replaceState and pushState (both from $app/navigation and window.history) do not go through the SvelteKit router. They update the browser URL bar but $page.url remains stale. Any $effect or $derived watching $page.url won't react.
Solution: Always use goto() for URL mutations.
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
// BAD — $page.url won't update
function openPanel(ref: string) {
const url = new URL(window.location.href);
url.searchParams.set('ref', ref);
window.history.pushState({}, '', url); // broken
}
// BAD — same problem with SvelteKit's replaceState
function closePanel() {
const url = new URL($page.url);
url.searchParams.delete('ref');
replaceState(url, {}); // broken
}
// GOOD — goes through router, $page.url updates reactively
function openPanel(ref: string) {
const url = new URL($page.url);
url.searchParams.set('ref', ref);
goto(url, { replaceState: true, keepFocus: true, noScroll: true });
}
function closePanel() {
const url = new URL($page.url);
url.searchParams.delete('ref');
goto(url, { replaceState: true, keepFocus: true, noScroll: true });
}
</script>
Key goto() options:
replaceState: true— don't pollute browser history with param changeskeepFocus: true— don't steal focus from the current elementnoScroll: true— don't scroll to top of page
Reference: SvelteKit issue #10661. This is intentional behavior — replaceState/pushState are for history state objects, not for reactive URL tracking.
Common Mistakes
1. Mixing Stores and Runes
Don't mix writable() stores with $state in the same component for related state. Pick one approach:
<!-- BAD -->
<script>
const open = writable(false); // Store
let item = $state(null); // Rune
</script>
<!-- GOOD -->
<script>
let open = $state(false);
let item = $state(null);
</script>
2. Using $ Prefix with $state
<!-- BAD -->
<script>
let count = $state(0);
</script>
<p>{$count}</p> <!-- Wrong! -->
<!-- GOOD -->
<script>
let count = $state(0);
</script>
<p>{count}</p>
3. Module-Level $state in Regular .ts Files
$state only works in .svelte and .svelte.ts files:
// lib/state.ts - BAD
let count = $state(0); // Won't work!
// lib/state.svelte.ts - GOOD
let count = $state(0); // Works
When to Use SSR vs Client-Only
Use SSR (ssr: true) |
Use Client-Only (ssr: false) |
|---|---|
| Content pages (SEO matters) | Dashboards |
| Landing pages | Admin panels |
| Blog posts | Interactive tools |
| Static marketing | Real-time data views |