Svelte Guidelines
Reference Repositories
- Svelte — Svelte 5 framework with runes and fine-grained reactivity
- shadcn-svelte — Port of shadcn/ui for Svelte with Bits UI primitives
- shadcn-svelte-extras — Additional components for shadcn-svelte
Related Skills: See
query-layerfor TanStack Query integration. Seestylingfor CSS and Tailwind conventions.
When to Apply This Skill
Use this pattern when you need to:
- Build Svelte 5 components that use TanStack Query mutations.
- Replace nested ternary
$derivedmappings withsatisfies Recordlookups. - Decide between
createMutationin.svelteand.execute()in.ts. - Follow shadcn-svelte import, composition, and component organization patterns.
- Refactor one-off
handle*wrappers into inline template actions. - Convert SvelteMap data to arrays for derived state or component props.
References
Load these on demand based on what you're working on:
- If working with TanStack Query mutations (
createMutation,.execute(),onSuccess/onError), read references/tanstack-query-mutations.md - If working with shadcn-svelte components (imports, composition, styling, customization), read references/shadcn-patterns.md
- If working with reactive state modules (
fromTable,fromKv,$derivedarrays, state factories), read references/reactive-state-pattern.md - If working with component architecture (props, inlining handlers, self-contained components, view-mode branching, data-driven markup), read references/component-patterns.md
- If working with loading or empty states (
Spinner,Empty.*,{#await}blocks), read references/loading-empty-states.md
$derived Value Mapping: Use satisfies Record, Not Ternaries
When 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:
- Compile-time exhaustiveness: add a value to the union and TypeScript errors on the missing key. Nested ternaries silently fall through.
- It's a data declaration, not control flow. The mapping is immediately visible.
$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.
When to Use SvelteMap vs $state
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 |
Anti-Pattern: $state for ID-Keyed Collections
// ❌ 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:
- O(n) lookups — every
.find()scans the whole array - Coarse reactivity — updating one item re-triggers everything reading the array
- Referential instability — sorting in a getter creates a new array every access, causing TanStack Table infinite loops
See docs/articles/sveltemap-over-state-for-keyed-collections.md for the full rationale.
Reactive Table State Pattern
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).
With Sort or Filter
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).
Template Props
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} />
Why $derived, Not a Plain Getter
Put 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.
Reactive State Module Conventions
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();
Naming
| 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).
Accessor Patterns
| 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).
Persisted State Utilities
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.
Mutation Pattern Preference
In Svelte Files (.svelte)
Always prefer createMutation from TanStack Query for mutations. This provides:
- Loading states (
isPending) - Error states (
isError) - Success states (
isSuccess) - Better UX with automatic state management
The Preferred Pattern
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>
Why This Pattern?
- More context: Access to local variables and state at the call site
- Better organization: Success/error handling is co-located with the action
- Flexibility: Different calls can have different success/error behaviors
In TypeScript Files (.ts)
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
}
Exception: When to Use .execute() in Svelte Files
Only use .execute() in Svelte files when:
- You don't need loading states
- You're performing a one-off operation
- You need fine-grained control over async flow
Single-Use Functions: Inline or Document
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.
Why Inline?
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();
}
}} />
The Exception: JSDoc + Semantic Name
Keep a single-use function extracted only when both conditions are met:
- It has JSDoc explaining why it exists as a named unit.
- The name provides a clear semantic meaning that makes the template more readable than the inlined version would be.
<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.
Multi-Use Functions
Functions used 2 or more times should always stay extracted — this rule only applies to single-use functions.
Styling
For general CSS and Tailwind guidelines, see the styling skill.
shadcn-svelte Best Practices
Component Organization
- Use the CLI:
bunx shadcn-svelte@latest add [component] - Each component in its own folder under
$lib/components/ui/with anindex.tsexport - Follow kebab-case for folder names (e.g.,
dialog/,toggle-group/) - Group related sub-components in the same folder
- When using $state, $derived, or functions only referenced once in markup, inline them directly
Import Patterns
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).
Styling and Customization
- Always use the
cn()utility from$lib/utilsfor combining Tailwind classes - Modify component code directly rather than overriding styles with complex CSS
- Use
tailwind-variantsfor component variant systems - Follow the
background/foregroundconvention for colors - Leverage CSS variables for theme consistency
Component Usage Patterns
Use 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>
Custom Components
- When extending shadcn components, create wrapper components that maintain the design system
- Add JSDoc comments for complex component props
- Ensure custom components follow the same organizational patterns
- Consider semantic appropriateness (e.g., use section headers instead of cards for page sections)
Props Pattern
Always Inline Props Types
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>
Children Prop Never Needs Type Annotation
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>
Self-Contained Component Pattern
Prefer Component Composition Over Parent State Management
When building interactive components (especially with dialogs/modals), create self-contained components rather than managing state at the parent level.
The Anti-Pattern (Parent State Management)
<!-- 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>
The Pattern (Self-Contained Components)
<!-- 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}
Why This Pattern Works
- No parent state pollution: Parent doesn't need to track which item is being deleted
- Better encapsulation: All delete logic lives in one place
- Simpler mental model: Each row has its own delete button with its own dialog
- No callbacks needed: Component handles everything internally
- Scales better: Adding new actions doesn't complicate the parent
When to Apply This Pattern
- Action buttons in table rows (delete, edit, etc.)
- Confirmation dialogs for list items
- Any repeating UI element that needs modal interactions
- When you find yourself passing callbacks just to update parent state
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.
Referential Stability for Reactive Data Sources
The Problem: New Array = Infinite Loop with TanStack Table
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.
The Fix: Memoize with $derived
In .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;
}
When This Matters
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())
Rule of Thumb
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.
Loading and Empty State Patterns
Never Use Plain Text for Loading States
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 loading- Button loading states
Full-Page Loading (Async Gate)
When 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}
Inline Loading (Conditional)
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}
Button Loading State
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>
Empty State (No Data)
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>
Key Rules
- Never show plain text ("Loading...", "Loading tabs…") without a
Spinner - Always include
{:catch}on{#await}blocks — prevents infinite spinner on failure - Use
text-muted-foregroundfor loading text and spinner color - Use
size-5for full-page spinners,size-3.5for inline/button spinners - Match the
Empty.*compound component pattern for both error and empty states
Prop-First Data Derivation
When 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>
Why This Matters
- Self-describing: The component works correctly regardless of which view rendered it.
- Fewer imports: Dropping a global state import reduces coupling.
- Testable: Pass a note with
deletedAtset and the component behaves correctly — no need to mock view state.
The Rule
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.
Styling
For general CSS and Tailwind guidelines, see the styling skill.