svelte by epicenterhq/epicenter
npx skills add https://github.com/epicenterhq/epicenter --skill svelte相关技能:关于 TanStack Query 集成,请参阅
query-layer。关于 CSS 和 Tailwind 规范,请参阅styling。
在以下情况下使用此模式:
satisfies Record 查找替换嵌套的三元 $derived 映射。.svelte 中使用 createMutation 还是在 中使用 。广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
.ts.execute()handle* 包装器重构为内联模板操作。根据您正在处理的工作按需加载:
createMutation, .execute(), onSuccess/onError),请阅读 references/tanstack-query-mutations.mdfromTable, fromKv, $derived 数组、状态工厂),请阅读 references/reactive-state-pattern.mdSpinner, Empty.*, {#await} 块),请阅读 references/loading-empty-states.md$derived 值映射:使用 satisfies Record,而非三元表达式当 $derived 表达式将有限联合类型映射到输出值时,请使用 satisfies Record 查找。切勿使用嵌套三元表达式。切勿使用带有 switch 的 $derived.by() 仅用于映射值。
<!-- 错误:在 $derived 中使用嵌套三元表达式 -->
<script lang="ts">
const tooltip = $derived(
syncStatus.current === 'connected'
? 'Connected'
: syncStatus.current === 'connecting'
? 'Connecting…'
: 'Offline',
);
</script>
<!-- 错误:使用带 switch 的 $derived.by 进行纯值查找 -->
<script lang="ts">
const tooltip = $derived.by(() => {
switch (syncStatus.current) {
case 'connected': return 'Connected';
case 'connecting': return 'Connecting…';
case 'offline': return 'Offline';
}
});
</script>
<!-- 正确:使用 satisfies Record 的 $derived -->
<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>
为何 satisfies Record 更优:
$derived() 保持为单个表达式 — 无需使用 $derived.by()。保留 $derived.by() 用于确实需要函数体的多语句逻辑。对于值查找,请保持使用带有记录的 $derived()。
使用 satisfies 时无需 as const。satisfies Record<T, string> 已经验证了形状和值类型。
理由请参阅 docs/articles/record-lookup-over-nested-ternaries.md。
当工厂函数通过 fromTable 公开工作区表格数据时,请遵循此三层约定:
// 1. Map — 响应式源(私有,以 Map 为后缀)
const foldersMap = fromTable(workspaceClient.tables.folders);
// 2. Derived array — 缓存的物化结果(私有,无后缀)
const folders = $derived(foldersMap.values().toArray());
// 3. Getter — 公共 API(与派生名称匹配)
return {
get folders() {
return folders;
},
};
命名:{name}Map (私有源) → {name} (缓存的派生) → get {name}() (公共 getter)。
在 $derived 内部链接操作 — 整个管道都会被缓存:
const tabs = $derived(tabsMap.values().toArray().sort((a, b) => b.savedAt - a.savedAt));
const notes = $derived(allNotes.filter((n) => n.deletedAt === undefined));
关于迭代器辅助方法 (.toArray(), .filter(), .find() on IteratorObject),请参阅 typescript 技能。
对于期望 T[] 的组件属性,请在脚本块中派生 — 切勿在模板中物化:
<!-- 错误:每次渲染都重新创建数组 -->
<FujiSidebar entries={entries.values().toArray()} />
<!-- 正确:通过 $derived 缓存 -->
<script>
const entriesArray = $derived(entries.values().toArray());
</script>
<FujiSidebar entries={entriesArray} />
$derived,而非普通 Getter将响应式计算放在 $derived 中,而非公共 getter 内部。
Getter 如果读取响应式状态,可能仍然是响应式的,但每次访问都会重新计算。$derived 会响应式计算并在依赖项更改前缓存结果。
使用 $derived 进行计算。仅将 getter 用作传递通道以公开该派生值。
理由请参阅 docs/articles/derived-vs-getter-caching-matters.md。
状态模块使用一个工厂函数,该函数返回一个包含 getter 和方法的扁平对象,并作为单例导出。
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();
| 关注点 | 约定 | 示例 |
|---|---|---|
| 导出名称 | 领域状态使用 xState;工具使用描述性名词 | bookmarkState, notesState, deviceConfig, vadRecorder |
| 工厂函数 | createX() 与导出名称匹配 | createBookmarkState() |
| 文件名 | 领域名称,可选带 -state 后缀 | bookmark-state.svelte.ts, auth.svelte.ts |
当导出名称可能与关键属性冲突时,使用 State 后缀 (bookmarkState.bookmarks,而非 bookmarks.bookmarks)。
| 数据形状 | 访问器 | 示例 |
|---|---|---|
| 集合 | 命名 getter | bookmarkState.bookmarks, notesState.notes |
| 单个响应式值 | .current (Svelte 5 约定) | selectedFolderId.current, serverUrl.current |
| 键值查找 | .get(key) | toolTrustState.get(name), deviceConfig.get(key) |
.current 约定源自 runed (标准 Svelte 5 工具库)。所有 34+ 个 runed 工具都使用 .current。切勿使用 .value (Vue 约定)。
对于 localStorage/sessionStorage 持久化,请使用来自 @epicenter/svelte 的 createPersistedState (单值) 或 createPersistedMap (类型化多键配置)。
// 单值 — .current 访问器
import { createPersistedState } from '@epicenter/svelte';
const theme = createPersistedState({
key: 'app-theme',
schema: type("'light' | 'dark'"),
defaultValue: 'dark',
});
theme.current; // 读取
theme.current = 'light'; // 写入 + 持久化
// 多键配置 — 使用 SvelteMap 的 .get()/.set() (每个键的响应式)
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'); // 类型化读取
config.set('theme', 'light'); // 类型化写入 + 持久化
两者都接受 storage?: Storage (默认为 window.localStorage) 用于依赖注入。
始终优先使用 TanStack Query 的 createMutation 进行变更。这提供了:
isPending)isError)isSuccess)将 onSuccess 和 onError 作为第二个参数传递给 .mutate() 以获得最大上下文:
<script lang="ts">
import { createMutation } from '@tanstack/svelte-query';
import * as rpc from '$lib/query';
// 将 .options 包装在访问器函数中,.options 不加括号
// 根据其功能命名,不要使用 "Mutation" 后缀(冗余)
const deleteSession = createMutation(
() => rpc.sessions.deleteSession.options,
);
// 我们可以在回调中访问的本地状态
let isDialogOpen = $state(false);
</script>
<Button
onclick={() => {
// 将回调作为第二个参数传递给 .mutate()
deleteSession.mutate(
{ sessionId },
{
onSuccess: () => {
// 访问本地状态和上下文
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>
始终使用 .execute(),因为 createMutation 需要组件上下文:
// 在 .ts 文件中(例如,加载函数、工具函数)
const result = await rpc.sessions.createSession.execute({
body: { title: 'New Session' },
});
const { data, error } = result;
if (error) {
// 处理错误
} else if (data) {
// 处理成功
}
仅在以下情况下在 Svelte 文件中使用 .execute():
如果函数在脚本标签中定义且仅在模板中使用一次,请将其内联到调用点。这适用于事件处理程序、回调函数和任何其他单次使用逻辑。
提取的单次使用函数增加了间接性 — 读者需要在函数定义和模板之间跳转才能理解点击/按键等操作时发生了什么。内联将因果关系保持在操作发生的点。
<!-- 错误:提取的单次使用函数,没有 JSDoc 或语义价值 -->
<script>
function handleShare() {
share.mutate({ id });
}
function handleSelectItem(itemId: string) {
goto(`/items/${itemId}`);
}
</script>
<Button onclick={handleShare}>Share</Button>
<Item onclick={() => handleSelectItem(item.id)} />
<!-- 正确:在调用点内联 -->
<Button onclick={() => share.mutate({ id })}>Share</Button>
<Item onclick={() => goto(`/items/${item.id}`)} />
这也适用于较长的处理程序。如果逻辑是线性的(守卫子句 + 分支,而非深度嵌套),即使有 10–15 行,也将其内联:
<!-- 正确:内联的键盘快捷键处理程序 -->
<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();
}
}} />
仅在满足以下两个条件时,才保留提取的单次使用函数:
<script lang="ts">
/**
* 使用箭头键导航笔记列表,在边界处循环。
* 对扁平化的显示顺序 ID 列表进行操作,以尊重日期分组。
*/
function navigateWithArrowKeys(e: KeyboardEvent) {
// 15 行键盘导航逻辑...
}
</script>
<!-- 语义名称比内联逻辑更能传达意图 -->
<div onkeydown={navigateWithArrowKeys} tabindex="-1">
如果没有 JSDoc 和有意义的名称,无论如何都要提取它 — 间接性不值得保留。
使用 2 次或更多次 的函数应始终保留提取 — 此规则仅适用于单次使用函数。
关于通用的 CSS 和 Tailwind 指南,请参阅 styling 技能。
bunx shadcn-svelte@latest add [component]$lib/components/ui/ 下,并带有 index.ts 导出dialog/, toggle-group/)命名空间导入 (多部分组件首选):
import * as Dialog from '$lib/components/ui/dialog';
import * as ToggleGroup from '$lib/components/ui/toggle-group';
命名导入 (用于单个组件):
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
Lucide 图标 (始终使用来自 @lucide/svelte 的单独导入):
// 正确:单独图标导入
import Database from '@lucide/svelte/icons/database';
import MinusIcon from '@lucide/svelte/icons/minus';
import MoreVerticalIcon from '@lucide/svelte/icons/more-vertical';
// 错误:不要从 lucide-svelte 导入多个图标
import { Database, MinusIcon, MoreVerticalIcon } from 'lucide-svelte';
路径使用 kebab-case (例如 more-vertical, minimize-2),您可以随意命名导入 (通常是 PascalCase,可选带 Icon 后缀)。
$lib/utils 的 cn() 工具来组合 Tailwind 类tailwind-variants 处理组件变体系统background/foreground 约定遵循 shadcn-svelte 模式使用正确的组件组合:
<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>
切勿创建单独的 type Props = {...} 声明。始终直接在 $props() 中内联类型:
<!-- 错误:单独的 Props 类型 -->
<script lang="ts">
type Props = {
selectedWorkspaceId: string | undefined;
onSelect: (id: string) => void;
};
let { selectedWorkspaceId, onSelect }: Props = $props();
</script>
<!-- 正确:内联属性类型 -->
<script lang="ts">
let { selectedWorkspaceId, onSelect }: {
selectedWorkspaceId: string | undefined;
onSelect: (id: string) => void;
} = $props();
</script>
children 属性在 Svelte 中是隐式类型的。永远不要注解它:
<!-- 错误:注解 children -->
<script lang="ts">
let { children }: { children: Snippet } = $props();
</script>
<!-- 正确:children 是隐式类型的 -->
<script lang="ts">
let { children } = $props();
</script>
<!-- 正确:其他属性需要类型,但 children 不需要 -->
<script lang="ts">
let { children, title, onClose }: {
title: string;
onClose: () => void;
} = $props();
</script>
构建交互式组件(尤其是带有对话框/模态框的组件)时,创建自包含组件,而非在父级管理状态。
<!-- 父组件 -->
<script>
let deletingItem = $state(null);
</script>
{#each items as item}
<Button onclick={() => (deletingItem = item)}>Delete</Button>
{/each}
<AlertDialog open={!!deletingItem}>
<!-- 所有项目共用一个对话框 -->
</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>
<!-- 父组件 -->
{#each items as item}
<DeleteItemButton {item} />
{/each}
关键见解:实例化多个对话框(每行一个)而不是管理具有复杂状态的单个共享对话框是完全可行的。现代框架能高效处理此情况,代码清晰性值得这样做。
当将来自响应式 SvelteMap(或任何基于信号的存储)的数据提供给 createSvelteTable 时,get data() getter 必须返回一个 引用稳定 的数组。如果每次访问都创建新数组,TanStack Table 内部的 $derived 会进入无限循环:
1. $derived 调用 get data() → 新数组 (Array.from().sort())
2. TanStack Table 看到“数据更改” → 更新内部 $state (行模型)
3. $state 变更使 $derived 失效
4. $derived 重新运行 → get data() → 再次新数组 (总是新的!)
5. → 无限循环 → 页面冻结
TanStack Query 隐藏了这个问题,因为其缓存返回 相同的引用,直到重新获取。执行 Array.from(map.values()).sort() 的 SvelteMap getter 每次调用都会创建新数组。
$derived 记忆化在 .svelte.ts 模块中,使用 $derived 在每次 SvelteMap 更改时计算一次排序/过滤的数组:
// ❌ 错误:每次访问都创建新数组 → 与 TanStack Table 的无限循环
get sorted(): Recording[] {
return Array.from(map.values()).sort(
(a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(),
);
}
// ✅ 正确:$derived 缓存结果,在 SvelteMap 更改之间保持稳定引用
const sorted = $derived(
Array.from(map.values()).sort(
(a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(),
),
);
// 通过 getter 暴露(返回缓存的 $derived 值)
get sorted(): Recording[] {
return sorted;
}
仅当数组被 在响应式上下文中跟踪引用标识 的东西消费时,才会发生无限循环:
createSvelteTable({ get data() { ... } }) — 危险 (无限循环)$derived(someStore.sorted) 其中结果反馈到状态 — 危险{#each someStore.sorted as item} — 安全 (Svelte 的 each 块按值差异比较,每次更改渲染一次)$derived(someStore.get(id)) — 安全 (返回 SvelteMap.get() 中的现有对象引用)如果 .svelte.ts 状态模块有一个返回数组/对象的计算 getter,并且该 getter 可能被 TanStack Table 或反馈到 $state 的 $derived 链消费,始终使用 $derived 记忆化。成本几乎为零(一个额外的信号),并且可以防止一类在开发中不可见直到页面冻结的错误。
始终使用来自 @epicenter/ui/spinner 的 Spinner 组件,而非像 "Loading..." 这样的纯文本。这适用于:
{#await} 块{#if} / {:else} 条件加载当基于异步 Promise(例如 whenReady, whenSynced)门控 UI 时,对加载和错误状态都使用 Empty.*。这保持了结构的对称性:
<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}
当加载状态由布尔值或空值检查控制时:
<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}
在按钮内部使用 Spinner,匹配 AuthForm 模式:
<Button onclick={handleAction} disabled={isPending}>
{#if isPending}<Spinner class="size-3.5" />{:else}Submit{/if}
</Button>
对空状态(无结果、无项目)使用 Empty.* 复合组件:
<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 的情况下显示纯文本 ("Loading...", "Loading tabs…"){#await} 块上包含 {:catch} — 防止失败时无限旋转text-muted-foregroundsize-5,对内联/按钮旋转器使用 size-3.5Empty.* 复合组件模式当组件接收的属性已经携带了决策所需的信息时,从属性派生。切勿为了组件已有的数据而访问全局状态。
<!-- 错误:为属性已携带的信息读取全局状态 -->
<script lang="ts">
import { viewState } from '$lib/state';
let { note }: { note: Note } = $props();
// viewState.isRecentlyDeletedView 是冗余的 — note.deletedAt 已有答案
const showRestoreActions = $derived(viewState.isRecentlyDeletedView);
</script>
<!-- 正确:从属性本身派生 -->
<script lang="ts">
let { note }: { note: Note } = $props();
// 笔记知道自己的状态 — 无需全局状态
const isDeleted = $derived(note.deletedAt !== undefined);
</script>
deletedAt 的笔记,组件行为正确 — 无需模拟视图状态。如果决策所需的数据已经在属性上(直接或可派生),始终 从属性派生。全局状态用于组件确实没有的信息。
关于通用的 CSS 和 Tailwind 指南,请参阅 styling 技能。
每周安装次数
131
仓库
GitHub 星标数
4.3K
首次出现时间
2026年1月20日
安全审计
安装于
opencode113
claude-code111
gemini-cli109
codex105
cursor99
github-copilot99
Related Skills : See
query-layerfor TanStack Query integration. Seestylingfor CSS and Tailwind conventions.
Use 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.Load these on demand based on what you're working on:
createMutation, .execute(), onSuccess/onError), read references/tanstack-query-mutations.mdfromTable, fromKv, $derived arrays, state factories), read references/reactive-state-pattern.md$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.
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() |
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) | , |
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.
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.
For general CSS and Tailwind guidelines, see the styling skill.
Weekly Installs
131
Repository
GitHub Stars
4.3K
First Seen
Jan 20, 2026
Security Audits
Gen Agent Trust HubPassSocketPassSnykPass
Installed on
opencode113
claude-code111
gemini-cli109
codex105
cursor99
github-copilot99
Genkit JS 开发指南:AI 应用构建、错误排查与最佳实践
7,700 周安装
Spinner, Empty.*, {#await} blocks), read references/loading-empty-states.md| File name |
Domain name, optionally with -state suffix |
bookmark-state.svelte.ts, auth.svelte.ts |
toolTrustState.get(name)deviceConfig.get(key)