Svelte 5 patterns including runes ($state, $derived, $props), TanStack Query, SvelteMap reactive state, shadcn-svelte components, and component composition. Use when the user mentions .svelte files, Svelte components, or when using TanStack Query, fromTable/fromKv, or shadcn-svelte UI.
Related Skills: See
query-layerfor TanStack Query integration. See for toast-on-error patterns (, ) when handling errors in components. See for CSS and Tailwind conventions, including the pattern (critical when building scrollable content inside , , or any flex column with siblings).
error-handlingtoastOnErrorextractErrorMessagestylingResizable.PaneScrollAreaUse this pattern when you need to:
$derived mappings with satisfies Record lookups.createMutation in .svelte and .execute() in .ts.handle* wrappers into inline template actions.{#each} or {#snippet} patterns.$derived Value Mapping: Use satisfies Record, Not TernariesWhen a $derived expression maps a finite union to output values, use a satisfies Record lookup. Never use nested ternaries. Never use $derived.by() with a switch just to map values.
<!-- Bad: nested ternary in $derived -->
<script lang="ts">
const tooltip = $derived(
syncStatus.current === 'connected'
? 'Connected'
: syncStatus.current === 'connecting'
? 'Connecting…'
: 'Offline',
);
</script>
<!-- Bad: $derived.by with switch for a pure value lookup -->
<script lang="ts">
const tooltip = $derived.by(() => {
switch (syncStatus.current) {
case 'connected': return 'Connected';
case 'connecting': return 'Connecting…';
case 'offline': return 'Offline';
}
});
</script>
<!-- Good: $derived with satisfies Record -->
<script lang="ts">
import type { SyncStatus } from '@epicenter/sync-client';
const tooltip = $derived(
({
connected: 'Connected',
connecting: 'Connecting…',
offline: 'Offline',
} satisfies Record<SyncStatus, string>)[syncStatus.current],
);
</script>
Why satisfies Record wins:
$derived() stays a single expression — no need for $derived.by().Reserve $derived.by() for multi-statement logic where you genuinely need a function body. For value lookups, keep it as $derived() with a record.
as const is unnecessary when using satisfies. satisfies Record<T, string> already validates shape and value types.
See docs/articles/record-lookup-over-nested-ternaries.md for rationale.
Use SvelteMap when items have stable IDs and you need keyed lookup. Use $state for primitives, local UI booleans, and sequential data without identity.
| Data Shape | Use | Example |
|---|---|---|
| Workspace table rows (have IDs) | fromTable() → SvelteMap | recordings, conversations, notes |
| Workspace KV (single key) | fromKv() | selectedFolderId, sortBy |
| Browser API keyed data | new SvelteMap() + listeners | Chrome tabs, windows |
| Primitive value | $state(value) | $state(false), $state(''), $state(0) |
| Sequential data without IDs | $state<T[]>([]) | terminal history, command history |
| Ordered list where position matters | $state<T[]>([]) | open file tab order |
// ❌ BAD: O(n) lookups, coarse reactivity, referential instability
let conversations = $state<Conversation[]>(readAll());
const metadata = $derived(conversations.find((c) => c.id === id)); // O(n) scan
// ✅ GOOD: O(1) lookups, per-key reactivity, stable $derived array
const conversationsMap = fromTable(workspace.tables.conversations);
const conversations = $derived(
conversationsMap.values().toArray().sort((a, b) => b.updatedAt - a.updatedAt),
);
const metadata = $derived(conversationsMap.get(id)); // O(1) lookup
Three problems with $state<T[]> for keyed data:
.find() scans the whole arraySee docs/articles/sveltemap-over-state-for-keyed-collections.md for the full rationale.
When a factory function exposes workspace table data via fromTable, follow this three-layer convention:
// 1. Map — reactive source (private, suffixed with Map)
const foldersMap = fromTable(workspaceClient.tables.folders);
// 2. Derived array — cached materialization (private, no suffix)
const folders = $derived(foldersMap.values().toArray());
// 3. Getter — public API (matches the derived name)
return {
get folders() {
return folders;
},
};
Naming: {name}Map (private source) → {name} (cached derived) → get {name}() (public getter).
Chain operations inside $derived — the entire pipeline is cached:
const tabs = $derived(tabsMap.values().toArray().sort((a, b) => b.savedAt - a.savedAt));
const notes = $derived(allNotes.filter((n) => n.deletedAt === undefined));
See the typescript skill for iterator helpers (.toArray(), .filter(), .find() on IteratorObject).
For component props expecting T[], derive in the script block — never materialize in the template:
<!-- Bad: re-creates array on every render -->
<FujiSidebar entries={entries.values().toArray()} />
<!-- Good: cached via $derived -->
<script>
const entriesArray = $derived(entries.values().toArray());
</script>
<FujiSidebar entries={entriesArray} />
$derived, Not a Plain GetterPut reactive computations in $derived, not inside public getters.
A getter may still be reactive if it reads reactive state, but it recomputes on every access. $derived computes reactively and caches until dependencies change.
Use $derived for the computation. Use the getter only as a pass-through to expose that derived value.
See docs/articles/derived-vs-getter-caching-matters.md for rationale.
State modules use a factory function that returns a flat object with getters and methods, exported as a singleton.
function createBookmarkState() {
const bookmarksMap = fromTable(workspaceClient.tables.bookmarks);
const bookmarks = $derived(bookmarksMap.values().toArray());
return {
get bookmarks() { return bookmarks; },
async add(tab: Tab) { /* ... */ },
remove(id: BookmarkId) { /* ... */ },
};
}
export const bookmarkState = createBookmarkState();
| Concern | Convention | Example |
|---|---|---|
| Export name | xState for domain state; descriptive noun for utilities | bookmarkState, notesState, deviceConfig, vadRecorder |
| Factory function | createX() matching the export name | createBookmarkState() |
| File name | Domain name, optionally with -state suffix | bookmark-state.svelte.ts, auth.svelte.ts |
Use the State suffix when the export name would collide with a key property (bookmarkState.bookmarks, not bookmarks.bookmarks).
| Data Shape | Accessor | Example |
|---|---|---|
| Collection | Named getter | bookmarkState.bookmarks, notesState.notes |
| Single reactive value | .current (Svelte 5 convention) | selectedFolderId.current, serverUrl.current |
| Keyed lookup | .get(key) | toolTrustState.get(name), deviceConfig.get(key) |
The .current convention comes from runed (the standard Svelte 5 utility library). All 34+ runed utilities use .current. Never use .value (Vue convention).
For localStorage/sessionStorage persistence, use createPersistedState (single value) or createPersistedMap (typed multi-key config) from @epicenter/svelte.
// Single value — .current accessor
import { createPersistedState } from '@epicenter/svelte';
const theme = createPersistedState({
key: 'app-theme',
schema: type("'light' | 'dark'"),
defaultValue: 'dark',
});
theme.current; // read
theme.current = 'light'; // write + persist
// Multi-key config — .get()/.set() with SvelteMap (per-key reactivity)
import { createPersistedMap, defineEntry } from '@epicenter/svelte';
const config = createPersistedMap({
prefix: 'myapp.config.',
definitions: {
'theme': defineEntry(type("'light' | 'dark'"), 'dark'),
'fontSize': defineEntry(type('number'), 14),
},
});
config.get('theme'); // typed read
config.set('theme', 'light'); // typed write + persist
Both accept storage?: Storage (defaults to window.localStorage) for dependency injection.
Always prefer createMutation from TanStack Query for mutations. This provides:
isPending)isError)isSuccess)Pass onSuccess and onError as the second argument to .mutate() to get maximum context:
<script lang="ts">
import { createMutation } from '@tanstack/svelte-query';
import * as rpc from '$lib/query';
// Wrap .options in accessor function, no parentheses on .options
// Name it after what it does, NOT with a "Mutation" suffix (redundant)
const deleteSession = createMutation(
() => rpc.sessions.deleteSession.options,
);
// Local state that we can access in callbacks
let isDialogOpen = $state(false);
</script>
<Button
onclick={() => {
// Pass callbacks as second argument to .mutate()
deleteSession.mutate(
{ sessionId },
{
onSuccess: () => {
// Access local state and context
isDialogOpen = false;
toast.success('Session deleted');
goto('/sessions');
},
onError: (error) => {
toast.error(error.title, { description: error.description });
},
},
);
}}
disabled={deleteSession.isPending}
>
{#if deleteSession.isPending}
Deleting...
{:else}
Delete
{/if}
</Button>
Always use .execute() since createMutation requires component context:
// In a .ts file (e.g., load function, utility)
const result = await rpc.sessions.createSession.execute({
body: { title: 'New Session' },
});
const { data, error } = result;
if (error) {
// Handle error
} else if (data) {
// Handle success
}
Only use .execute() in Svelte files when:
If a function is defined in the script tag and used only once in the template, inline it at the call site. This applies to event handlers, callbacks, and any other single-use logic.
Single-use extracted functions add indirection — the reader jumps between the function definition and the template to understand what happens on click/keydown/etc. Inlining keeps cause and effect together at the point where the action happens.
<!-- BAD: Extracted single-use function with no JSDoc or semantic value -->
<script>
function handleShare() {
share.mutate({ id });
}
function handleSelectItem(itemId: string) {
goto(`/items/${itemId}`);
}
</script>
<Button onclick={handleShare}>Share</Button>
<Item onclick={() => handleSelectItem(item.id)} />
<!-- GOOD: Inlined at the call site -->
<Button onclick={() => share.mutate({ id })}>Share</Button>
<Item onclick={() => goto(`/items/${item.id}`)} />
This also applies to longer handlers. If the logic is linear (guard clauses + branches, not deeply nested), inline it even if it's 10–15 lines:
<!-- GOOD: Inlined keyboard shortcut handler -->
<svelte:window onkeydown={(e) => {
const meta = e.metaKey || e.ctrlKey;
if (!meta) return;
if (e.key === 'k') {
e.preventDefault();
commandPaletteOpen = !commandPaletteOpen;
return;
}
if (e.key === 'n') {
e.preventDefault();
notesState.createNote();
}
}} />
Keep a single-use function extracted only when both conditions are met:
<script lang="ts">
/**
* Navigate the note list with arrow keys, wrapping at boundaries.
* Operates on the flattened display-order ID list to respect date grouping.
*/
function navigateWithArrowKeys(e: KeyboardEvent) {
// 15 lines of keyboard navigation logic...
}
</script>
<!-- The semantic name communicates intent better than inlined logic would -->
<div onkeydown={navigateWithArrowKeys} tabindex="-1">
Without JSDoc and a meaningful name, extract it anyway — the indirection isn't earning its keep.
Functions used 2 or more times should always stay extracted — this rule only applies to single-use functions.
For general CSS and Tailwind guidelines, see the styling skill.
bunx shadcn-svelte@latest add [component]$lib/components/ui/ with an index.ts exportdialog/, toggle-group/)Namespace imports (preferred for multi-part components):
import * as Dialog from '$lib/components/ui/dialog';
import * as ToggleGroup from '$lib/components/ui/toggle-group';
Named imports (for single components):
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
Lucide icons (always use individual imports from @lucide/svelte):
// Good: Individual icon imports
import Database from '@lucide/svelte/icons/database';
import MinusIcon from '@lucide/svelte/icons/minus';
import MoreVerticalIcon from '@lucide/svelte/icons/more-vertical';
// Bad: Don't import multiple icons from lucide-svelte
import { Database, MinusIcon, MoreVerticalIcon } from 'lucide-svelte';
The path uses kebab-case (e.g., more-vertical, minimize-2), and you can name the import whatever you want (typically PascalCase with optional Icon suffix).
cn() utility from $lib/utils for combining Tailwind classestailwind-variants for component variant systemsbackground/foreground convention for colorsUse proper component composition following shadcn-svelte patterns:
<Dialog.Root bind:open={isOpen}>
<Dialog.Trigger>
<Button>Open</Button>
</Dialog.Trigger>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Title</Dialog.Title>
</Dialog.Header>
</Dialog.Content>
</Dialog.Root>
Never create a separate type Props = {...} declaration. Always inline the type directly in $props():
<!-- BAD: Separate Props type -->
<script lang="ts">
type Props = {
selectedWorkspaceId: string | undefined;
onSelect: (id: string) => void;
};
let { selectedWorkspaceId, onSelect }: Props = $props();
</script>
<!-- GOOD: Inline props type -->
<script lang="ts">
let { selectedWorkspaceId, onSelect }: {
selectedWorkspaceId: string | undefined;
onSelect: (id: string) => void;
} = $props();
</script>
The children prop is implicitly typed in Svelte. Never annotate it:
<!-- BAD: Annotating children -->
<script lang="ts">
let { children }: { children: Snippet } = $props();
</script>
<!-- GOOD: children is implicitly typed -->
<script lang="ts">
let { children } = $props();
</script>
<!-- GOOD: Other props need types, but children does not -->
<script lang="ts">
let { children, title, onClose }: {
title: string;
onClose: () => void;
} = $props();
</script>
When building interactive components (especially with dialogs/modals), create self-contained components rather than managing state at the parent level.
<!-- Parent component -->
<script>
let deletingItem = $state(null);
</script>
{#each items as item}
<Button onclick={() => (deletingItem = item)}>Delete</Button>
{/each}
<AlertDialog open={!!deletingItem}>
<!-- Single dialog for all items -->
</AlertDialog>
<!-- DeleteItemButton.svelte -->
<script lang="ts">
import { createMutation } from '@tanstack/svelte-query';
import { rpc } from '$lib/query';
let { item }: { item: Item } = $props();
let open = $state(false);
const deleteItem = createMutation(() => rpc.items.delete.options);
</script>
<AlertDialog.Root bind:open>
<AlertDialog.Trigger>
<Button>Delete</Button>
</AlertDialog.Trigger>
<AlertDialog.Content>
<Button onclick={() => deleteItem.mutate({ id: item.id })}>
Confirm Delete
</Button>
</AlertDialog.Content>
</AlertDialog.Root>
<!-- Parent component -->
{#each items as item}
<DeleteItemButton {item} />
{/each}
The key insight: It's perfectly fine to instantiate multiple dialogs (one per row) rather than managing a single shared dialog with complex state. Modern frameworks handle this efficiently, and the code clarity is worth it.
If a component checks the same boolean flag (like isRecentlyDeletedView, isEditing, isCompact) in 3 or more template locations, the component is likely serving two purposes and should be considered for extraction.
<!-- SMELL: Same flag checked 3+ times -->
<script lang="ts">
const notes = $derived(
isRecentlyDeletedView ? deletedNotes : filteredNotes, // branch 1
);
</script>
{#if !isRecentlyDeletedView} <!-- branch 2 -->
<div>sort controls...</div>
{/if}
{#if isRecentlyDeletedView} <!-- branch 3 -->
No deleted notes
{:else}
No notes yet
{/if}
Move the view-mode decision to the parent. The child component takes the varying data as props:
<!-- Parent: one branch point, explicit data flow -->
{#if viewState.isRecentlyDeletedView}
<NoteList
notes={notesState.deletedNotes}
title="Recently Deleted"
showControls={false}
emptyMessage="No deleted notes"
/>
{:else}
<NoteList
notes={viewState.filteredNotes}
title={viewState.folderName}
/>
{/if}
The child becomes dumb — it renders what it's told, with zero awareness of view modes. This keeps the branching in one place instead of scattered across the component tree.
When 3 or more sequential sibling elements follow an identical pattern with only data varying, consider extracting the data into an array and using {#each} or a {#snippet}.
<!-- BAD: Copy-paste ×3 with only value/label changing -->
<DropdownMenu.Item onclick={() => setSortBy('dateEdited')}>
{#if sortBy === 'dateEdited'}<CheckIcon class="mr-2 size-4" />{/if}
Date Edited
</DropdownMenu.Item>
<DropdownMenu.Item onclick={() => setSortBy('dateCreated')}>
{#if sortBy === 'dateCreated'}<CheckIcon class="mr-2 size-4" />{/if}
Date Created
</DropdownMenu.Item>
<DropdownMenu.Item onclick={() => setSortBy('title')}>
{#if sortBy === 'title'}<CheckIcon class="mr-2 size-4" />{/if}
Title
</DropdownMenu.Item>
<!-- GOOD: Data-driven with {#each} -->
<script lang="ts">
const sortOptions = [
{ value: 'dateEdited' as const, label: 'Date Edited' },
{ value: 'dateCreated' as const, label: 'Date Created' },
{ value: 'title' as const, label: 'Title' },
];
</script>
{#each sortOptions as option}
<DropdownMenu.Item onclick={() => setSortBy(option.value)}>
{#if sortBy === option.value}
<CheckIcon class="mr-2 size-4" />
{:else}
<span class="mr-2 size-4"></span>
{/if}
{option.label}
</DropdownMenu.Item>
{/each}
For more complex repeated patterns (e.g., toolbar buttons with tooltips), use {#snippet} to define the shared structure once:
{#snippet toggleButton(pressed: boolean, onToggle: () => void, icon: typeof BoldIcon, label: string)}
<Tooltip.Root>
<Tooltip.Trigger>
<Toggle size="sm" {pressed} onPressedChange={onToggle}>
<svelte:component this={icon} class="size-4" />
</Toggle>
</Tooltip.Trigger>
<Tooltip.Content>{label}</Tooltip.Content>
</Tooltip.Root>
{/snippet}
{@render toggleButton(activeFormats.bold, () => editor?.chain().focus().toggleBold().run(), BoldIcon, 'Bold (⌘B)')}
{@render toggleButton(activeFormats.italic, () => editor?.chain().focus().toggleItalic().run(), ItalicIcon, 'Italic (⌘I)')}
When feeding data from a reactive SvelteMap (or any signal-based store) into createSvelteTable, the get data() getter must return a referentially stable array. If it creates a new array on every access, TanStack Table's internal $derived enters an infinite loop:
1. $derived calls get data() → new array (Array.from().sort())
2. TanStack Table sees "data changed" → updates internal $state (row model)
3. $state mutation invalidates the $derived
4. $derived re-runs → get data() → new array again (always new!)
5. → infinite loop → page freeze
TanStack Query hid this problem because its cache returns the same reference until a refetch. SvelteMap getters that do Array.from(map.values()).sort() create a new array every call.
$derivedIn .svelte.ts modules, use $derived to compute the sorted/filtered array once per SvelteMap change:
// ❌ BAD: New array on every access → infinite loop with TanStack Table
get sorted(): Recording[] {
return Array.from(map.values()).sort(
(a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(),
);
}
// ✅ GOOD: $derived caches the result, stable reference between SvelteMap changes
const sorted = $derived(
Array.from(map.values()).sort(
(a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(),
),
);
// Expose via getter (returns cached $derived value)
get sorted(): Recording[] {
return sorted;
}
The infinite loop only happens when the array is consumed by something that tracks reference identity in a reactive context:
createSvelteTable({ get data() { ... } }) — DANGEROUS (infinite loop)$derived(someStore.sorted) where the result feeds back into state — DANGEROUS{#each someStore.sorted as item} in a template — SAFE (Svelte's each block diffs by value, renders once per change)$derived(someStore.get(id)) — SAFE (returns existing object reference from SvelteMap.get())If a .svelte.ts state module has a computed getter that returns an array/object, and that getter could be consumed by TanStack Table or a $derived chain that feeds into $state, always memoize with $derived. The cost is near-zero (one extra signal), and it prevents a class of bugs that's invisible in development until the page freezes.
Always use the Spinner component from @epicenter/ui/spinner instead of plain text like "Loading...". This applies to:
{#await} blocks gating on async readiness{#if} / {:else} conditional loadingWhen gating UI on an async promise (e.g. whenReady, whenSynced), use Empty.* for both loading and error states. This keeps the structure symmetric:
<script lang="ts">
import * as Empty from '@epicenter/ui/empty';
import { Spinner } from '@epicenter/ui/spinner';
import TriangleAlertIcon from '@lucide/svelte/icons/triangle-alert';
</script>
{#await someState.whenReady}
<Empty.Root class="flex-1">
<Empty.Media>
<Spinner class="size-5 text-muted-foreground" />
</Empty.Media>
<Empty.Title>Loading tabs…</Empty.Title>
</Empty.Root>
{:then _}
<MainContent />
{:catch}
<Empty.Root class="flex-1">
<Empty.Media>
<TriangleAlertIcon class="size-8 text-muted-foreground" />
</Empty.Media>
<Empty.Title>Failed to load</Empty.Title>
<Empty.Description>Something went wrong. Try reloading.</Empty.Description>
</Empty.Root>
{/await}
When loading state is controlled by a boolean or null check:
<script lang="ts">
import { Spinner } from '@epicenter/ui/spinner';
</script>
{#if data}
<Content {data} />
{:else}
<div class="flex h-full items-center justify-center">
<Spinner class="size-5 text-muted-foreground" />
</div>
{/if}
Use Spinner inside the button, matching the AuthForm pattern:
<Button onclick={handleAction} disabled={isPending}>
{#if isPending}<Spinner class="size-3.5" />{:else}Submit{/if}
</Button>
Use the Empty.* compound component for empty states (no results, no items):
<script lang="ts">
import * as Empty from '@epicenter/ui/empty';
import FolderOpenIcon from '@lucide/svelte/icons/folder-open';
</script>
<Empty.Root class="py-8">
<Empty.Media>
<FolderOpenIcon class="size-8 text-muted-foreground" />
</Empty.Media>
<Empty.Title>No items found</Empty.Title>
<Empty.Description>Create an item to get started</Empty.Description>
</Empty.Root>
Spinner{:catch} on {#await} blocks — prevents infinite spinner on failuretext-muted-foreground for loading text and spinner colorsize-5 for full-page spinners, size-3.5 for inline/button spinnersEmpty.* compound component pattern for both error and empty statesWhen a component receives a prop that already carries the information needed for a decision, derive from the prop. Never reach into global state for data the component already has.
<!-- BAD: Reading global state for info the prop already carries -->
<script lang="ts">
import { viewState } from '$lib/state';
let { note }: { note: Note } = $props();
// viewState.isRecentlyDeletedView is redundant — note.deletedAt has the answer
const showRestoreActions = $derived(viewState.isRecentlyDeletedView);
</script>
<!-- GOOD: Derive from the prop itself -->
<script lang="ts">
let { note }: { note: Note } = $props();
// The note knows its own state — no global state needed
const isDeleted = $derived(note.deletedAt !== undefined);
</script>
deletedAt set and the component behaves correctly — no need to mock view state.If the data needed for a decision is already on a prop (directly or derivable), always derive from the prop. Global state is for information the component genuinely doesn't have.
In Svelte, \uXXXX escape sequences work in JavaScript strings (inside <script> and {expressions}) but are treated as literal text in HTML template attributes and text content.
<!-- BAD: \u2026 renders as literal "\u2026" in the browser -->
<input placeholder="Search\u2026" />
<Tooltip.Content>Toggle terminal (\u2318`)</Tooltip.Content>
<p>Close the tab, reopen\u2014your notes are there.</p>
<!-- GOOD: Use actual unicode characters -->
<input placeholder="Search…" />
<Tooltip.Content>Toggle terminal (⌘`)</Tooltip.Content>
<p>Close the tab, reopen—your notes are there.</p>
JavaScript contexts are fine—these are standard JS string escapes:
<script>
// ✅ Works: JS string in <script>
createPlaceholderPlugin('Start writing\u2026');
</script>
<!-- ✅ Works: JS expression in template -->
{aiChatState.provider || 'Provider\u2026'}
{isLoading ? 'Loading\u2026' : 'Ready'}
Common characters affected: \u2014 (—), \u2026 (…), \u2318 (⌘), \u21e7 (⇧), \u2192 (→).
Rule: In HTML attributes and text content, always use the actual character. Reserve \uXXXX for JavaScript strings only.