| name | composable-svelte-core |
| description | Core architecture patterns for Composable Svelte. Use when creating stores, writing reducers, working with effects, composing reducers, or implementing business logic. Covers the Store-Reducer-Effect trinity, all 12 effect types, composition strategies (scope, combineReducers, forEach), and immutable state updates. |
Composable Svelte Core Architecture
This skill covers the fundamental patterns for building Composable Svelte applications: stores, reducers, effects, and composition strategies.
CRITICAL RULES
Rule 1: ALL State Must Be in the Store
Principle: Every piece of application state, including UI state, MUST live in the store. This is non-negotiable for testability.
❌ WRONG - Component State
<script lang="ts">
let isEditing = $state(false); // ❌ Not testable
let draftText = $state(''); // ❌ Not testable
let showModal = $state(false); // ❌ Not testable
</script>
✅ CORRECT - Store State
// State in store
interface TodoState {
text: string;
isEditing: boolean; // ✅ Testable with TestStore
draftText: string; // ✅ Testable with TestStore
}
type TodoAction =
| { type: 'startEdit' }
| { type: 'updateDraft'; draft: string }
| { type: 'commitEdit' };
WHY: Component state cannot be tested with TestStore. You'd need to mount components and simulate clicks. Store state can be tested with pure functions and send/receive pattern.
What Counts as State?
- ❌ NO
$statefor: Form values, editing flags, draft text, modal open/close, loading states, selected items, expanded/collapsed state - ✅ YES
$derivedfor: Computing values from store, filtering/mapping, formatting for display - ✅ YES local vars for: DOM refs (
bind:this), constants
Rule 2: Pragmatic Abstraction
Principle: Different data structures need different patterns. Apply abstraction where value is high, use simple helpers elsewhere.
Decision Matrix
| Structure | Pattern | Why |
|---|---|---|
| Flat collections (todos, counters) | forEach + scopeToElement |
Items independent, isolation valuable (92% boilerplate reduction) |
| Recursive trees (folders, org charts) | Helper functions + store + ID |
Relationships matter, structure explicit |
| Optional children (modals, sheets) | ifLet + PresentationAction |
State-driven navigation |
| Permanent children (sections) | scope() + combineReducers |
Clear boundaries |
WHY: Composable Architecture's value comes from predictability and testability, not uniformity. Use the right tool for each structure.
CORE PATTERNS
Pattern 1: Store Auto-Subscription
IMPORTANT: Stores implement Svelte's store contract via the subscribe() method. This means you can use Svelte's $store syntax for automatic subscription - ZERO boilerplate!
✅ CORRECT - Auto-Subscription (Recommended)
<script lang="ts">
import { createStore } from '@composable-svelte/core';
const store = createStore({
initialState: { count: 0, isLoading: false },
reducer: counterReducer,
dependencies: { api }
});
// Use $store directly - automatic subscription!
const displayText = $derived(`Count: ${$store.count}`);
</script>
{#if $store.isLoading}
<p>Loading...</p>
{:else}
<p>{displayText}</p>
<button onclick={() => store.dispatch({ type: 'increment' })}>
Increment
</button>
{/if}
❌ WRONG - Manual Subscription (Unnecessary)
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
const store = createStore({...});
// ❌ Unnecessary manual subscription
let state = $state(store.state);
let unsubscribe: (() => void) | null = null;
onMount(() => {
unsubscribe = store.subscribe((newState) => {
state = newState;
});
});
onDestroy(() => {
unsubscribe?.();
});
// Using local state variable
const displayText = $derived(`Count: ${state.count}`);
</script>
{#if state.isLoading}
<p>Loading...</p>
{/if}
Why $store Works: The store implements Svelte's store contract with a subscribe() method that takes a callback and returns an unsubscribe function. Svelte's compiler automatically handles subscription/unsubscription when you use the $ prefix.
When to Use Manual Subscription: Only when you need to transform or wrap the store for specific integration patterns (e.g., form reactive wrappers). For normal component usage, always use $store.
Pattern 2: Store-Reducer-Effect Trinity
The fundamental pattern for every feature.
Complete Template
// 1. Define State (ALL application state)
interface FeatureState {
items: Item[];
isLoading: boolean;
error: string | null;
selectedId: string | null;
}
// 2. Define Actions (Discriminated Union)
type FeatureAction =
| { type: 'loadItems' }
| { type: 'itemsLoaded'; items: Item[] }
| { type: 'loadFailed'; error: string }
| { type: 'selectItem'; id: string }
| { type: 'clearSelection' };
// 3. Define Dependencies
interface FeatureDependencies {
api: APIClient;
clock: Clock;
}
// 4. Reducer (Pure Function)
const featureReducer: Reducer<FeatureState, FeatureAction, FeatureDependencies> = (
state,
action,
deps
) => {
switch (action.type) {
case 'loadItems':
return [
{ ...state, isLoading: true, error: null },
Effect.run(async (dispatch) => {
const result = await deps.api.get<Item[]>('/items');
if (result.ok) {
dispatch({ type: 'itemsLoaded', items: result.data });
} else {
dispatch({ type: 'loadFailed', error: result.error });
}
})
];
case 'itemsLoaded':
return [
{ ...state, items: action.items, isLoading: false },
Effect.none()
];
case 'loadFailed':
return [
{ ...state, error: action.error, isLoading: false },
Effect.none()
];
case 'selectItem':
return [
{ ...state, selectedId: action.id },
Effect.none()
];
case 'clearSelection':
return [
{ ...state, selectedId: null },
Effect.none()
];
default:
// Exhaustiveness check
const _never: never = action;
return [state, Effect.none()];
}
};
// 5. Component
// Feature.svelte
<script lang="ts">
import { createStore } from '@composable-svelte/core';
import { featureReducer } from './reducer';
const store = createStore({
initialState: {
items: [],
isLoading: false,
error: null,
selectedId: null
},
reducer: featureReducer,
dependencies: {
api: createAPIClient(),
clock: new SystemClock()
}
});
// Load on mount
$effect(() => {
store.dispatch({ type: 'loadItems' });
});
</script>
{#if $store.isLoading}
<p>Loading...</p>
{:else if $store.error}
<p class="error">{$store.error}</p>
{:else}
<ul>
{#each $store.items as item (item.id)}
<li
class:selected={$store.selectedId === item.id}
onclick={() => store.dispatch({ type: 'selectItem', id: item.id })}
>
{item.name}
</li>
{/each}
</ul>
{/if}
Checklist
- State interface defines ALL application state
- Actions are discriminated union with
typefield - Reducer is pure function (no side effects)
- Immutable updates (
{ ...state, field: newValue }) - Effects return data structures, not executed in reducer
- Exhaustiveness check in default case
- Component has NO
$statefor application state - Component reads from
$store, dispatches actions
EFFECT SYSTEM (12 Types)
Effect Decision Tree
What kind of side effect do you need?
│
├─ Pure state update → Effect.none()
├─ Async operation that dispatches → Effect.run()
├─ Fire-and-forget (analytics, logging) → Effect.fireAndForget()
├─ Multiple parallel effects → Effect.batch()
├─ Cancel previous effect → Effect.cancel() + Effect.cancellable()
├─ Delay user input (search-as-you-type) → Effect.debounced()
├─ Limit frequency (scroll events) → Effect.throttled()
├─ Wait before dispatching → Effect.afterDelay()
├─ Long-running (WebSocket, SSE) → Effect.subscription()
├─ Animation timing → Effect.animated()
└─ PresentationState lifecycle → Effect.transition()
Effect.none() - Pure State Update
case 'selectItem':
return [
{ ...state, selectedId: action.id },
Effect.none() // No side effects
];
Effect.run() - Async with Dispatch
case 'loadData':
return [
{ ...state, isLoading: true },
Effect.run(async (dispatch) => {
const result = await api.getData();
if (result.ok) {
dispatch({ type: 'dataLoaded', data: result.data });
} else {
dispatch({ type: 'loadFailed', error: result.error });
}
})
];
Effect.fireAndForget() - No Dispatch Needed
case 'buttonClicked':
return [
{ ...state, clickCount: state.clickCount + 1 },
Effect.fireAndForget(async () => {
await analytics.track('button_clicked');
})
];
Effect.batch() - Multiple Parallel Effects
case 'pageLoaded':
return [
{ ...state, isLoading: true },
Effect.batch(
Effect.run(async (d) => {
const user = await api.getUser();
d({ type: 'userLoaded', user });
}),
Effect.run(async (d) => {
const settings = await api.getSettings();
d({ type: 'settingsLoaded', settings });
})
)
];
Effect.debounced() - Search as You Type
case 'searchTextChanged':
return [
{ ...state, searchText: action.text },
Effect.debounced('search', 300, async (dispatch) => {
const results = await api.search(action.text);
dispatch({ type: 'searchResults', results });
})
];
Effect.cancellable() - Cancel Previous Request
case 'search':
return [
{ ...state, query: action.query, isSearching: true },
Effect.cancellable('search-request', async (dispatch) => {
const results = await api.search(action.query);
dispatch({ type: 'searchCompleted', results });
})
];
case 'clearSearch':
return [
{ ...state, query: '', results: [], isSearching: false },
Effect.cancel('search-request')
];
Effect.throttled() - Limit Frequency
case 'scrolled':
return [
{ ...state, scrollY: action.y },
Effect.throttled('scroll-handler', 100, async (dispatch) => {
// Heavy computation
const visibleItems = computeVisibleItems(action.y);
dispatch({ type: 'visibleItemsChanged', items: visibleItems });
})
];
Effect.afterDelay() - Timed Dispatch
case 'showToast':
return [
{ ...state, toast: action.message },
Effect.afterDelay(3000, (dispatch) => {
dispatch({ type: 'hideToast' });
})
];
Effect.subscription() - Long-Running
case 'connectWebSocket':
return [
{ ...state, connectionStatus: 'connecting' },
Effect.subscription('ws', (dispatch) => {
const ws = new WebSocket('wss://api.example.com');
ws.onopen = () => {
dispatch({ type: 'connected' });
};
ws.onmessage = (event) => {
dispatch({ type: 'messageReceived', data: JSON.parse(event.data) });
};
// Cleanup function
return () => {
ws.close();
};
})
];
case 'disconnect':
return [
{ ...state, connectionStatus: 'disconnected' },
Effect.cancel('ws')
];
Effect.transition() - PresentationState Lifecycle
Best for: PresentationState-based animations (modals, sheets, drawers)
What it does: Returns { present, dismiss } effects configured with durations. Simplifies PresentationState lifecycle management.
// Define transition once
const transition = Effect.transition({
presentDuration: 300,
dismissDuration: 200,
createPresentationEvent: (event) => ({
type: 'presentation',
event
})
});
// Use in reducer
case 'showModal':
return [
{
...state,
content,
presentation: { status: 'presenting', content, duration: 0.3 }
},
transition.present // Dispatches presentationCompleted after 300ms
];
case 'hideModal':
return [
{
...state,
presentation: { status: 'dismissing', content: state.presentation.content, duration: 0.2 }
},
transition.dismiss // Dispatches dismissalCompleted after 200ms
];
case 'presentation':
if (action.event.type === 'presentationCompleted') {
return [
{ ...state, presentation: { status: 'presented', content: state.presentation.content } },
Effect.none()
];
}
if (action.event.type === 'dismissalCompleted') {
return [
{ ...state, content: null, presentation: { status: 'idle' } },
Effect.none()
];
}
return [state, Effect.none()];
Why use transition(): Cleaner than manual Effect.afterDelay() for PresentationState. Encapsulates the timing logic.
COMPOSITION STRATEGIES
Strategy 1: scope() - Permanent Child
When: Child is always present (counter in app, settings panel, permanent UI section)
// Parent state
interface AppState {
counter: CounterState;
theme: 'light' | 'dark';
}
// Parent actions
type AppAction =
| { type: 'counter'; action: CounterAction }
| { type: 'toggleTheme' };
// Compose with scope()
import { scope } from '@composable-svelte/core';
const appReducer: Reducer<AppState, AppAction> = (state, action, deps) => {
switch (action.type) {
case 'counter':
// Delegate to child via scope()
return scope(
(s) => s.counter, // Get child state
(s, c) => ({ ...s, counter: c }), // Set child state
(a) => a.type === 'counter' ? a.action : null, // Extract child action
(ca) => ({ type: 'counter', action: ca }), // Lift child action
counterReducer
)(state, action, deps);
case 'toggleTheme':
return [
{ ...state, theme: state.theme === 'light' ? 'dark' : 'light' },
Effect.none()
];
default:
const _never: never = action;
return [state, Effect.none()];
}
};
Strategy 2: combineReducers() - Multiple Slices
When: Multiple independent sections sharing the same action type (Redux-style slices)
import { combineReducers } from '@composable-svelte/core';
interface AppState {
user: UserState;
posts: PostsState;
comments: CommentsState;
}
const appReducer = combineReducers<AppState, AppAction>({
user: userReducer,
posts: postsReducer,
comments: commentsReducer
});
Strategy 3: forEach() - Flat Collection
When: Independent items that don't know about each other (todo list, product grid)
// State
interface TodosState {
todos: TodoState[];
}
interface TodoState {
id: string;
text: string;
completed: boolean;
}
type TodoAction =
| { type: 'toggle' }
| { type: 'delete' };
// Single todo reducer
const todoReducer: Reducer<TodoState, TodoAction> = (state, action) => {
switch (action.type) {
case 'toggle':
return [{ ...state, completed: !state.completed }, Effect.none()];
case 'delete':
// Parent will handle removal
return [state, Effect.none()];
default:
return [state, Effect.none()];
}
};
// Collection reducer with forEach()
import { integrate } from '@composable-svelte/core';
const todosReducer = integrate<TodosState, any, Deps>()
.forEach('todo', s => s.todos, (s, todos) => ({ ...s, todos }), todoReducer)
.build();
// Component
import { scopeToElement } from '@composable-svelte/core';
{#each $store.todos as todo (todo.id)}
{@const todoStore = scopeToElement(store, 'todo', todo.id)}
<Todo store={todoStore} />
{/each}
Strategy 4: Tree Utilities - Recursive Structures
When: Hierarchical data with parent-child relationships (file systems, org charts, nested menus)
Why NOT forEach: Trees have relationships between nodes, structure needs to be explicit. Per DESIGN-PRINCIPLES.md, use simple helpers over complex abstractions for trees.
// 1. Define tree node types
type FileNode = { type: 'file'; id: string; name: string };
type FolderNode = { type: 'folder'; id: string; name: string; children: Node[]; isExpanded: boolean };
type Node = FileNode | FolderNode;
// 2. Create tree helpers
import { createTreeHelpers } from '@composable-svelte/core/utils/tree';
const treeHelpers = createTreeHelpers<Node>({
getId: (node) => node.id,
getChildren: (node) => node.type === 'folder' ? node.children : undefined,
setChildren: (node, children) =>
node.type === 'folder' ? { ...node, children } : node
});
// 3. Use in reducer with node ID
interface FileSystemState {
root: Node[];
selectedId: string | null;
}
type FileSystemAction =
| { type: 'toggleExpand'; folderId: string }
| { type: 'renameNode'; nodeId: string; newName: string }
| { type: 'deleteNode'; nodeId: string };
const fileSystemReducer: Reducer<FileSystemState, FileSystemAction> = (state, action) => {
switch (action.type) {
case 'toggleExpand': {
const updated = treeHelpers.updateNode(state.root, action.folderId, (node) =>
node.type === 'folder' ? { ...node, isExpanded: !node.isExpanded } : node
);
return [{ ...state, root: updated || state.root }, Effect.none()];
}
case 'renameNode': {
const updated = treeHelpers.updateNode(state.root, action.nodeId, (node) => ({
...node,
name: action.newName
}));
return [{ ...state, root: updated || state.root }, Effect.none()];
}
case 'deleteNode': {
const updated = treeHelpers.deleteNode(state.root, action.nodeId);
return [{ ...state, root: updated || state.root }, Effect.none()];
}
default:
return [state, Effect.none()];
}
};
// 4. Component passes ID, not scoped store
// Folder.svelte
<script lang="ts">
export let store: Store<FileSystemState, FileSystemAction>;
export let folderId: string; // Component knows its ID
const folder = $derived(treeHelpers.findNode($store.root, folderId) as FolderNode);
</script>
<div>
<button onclick={() => store.dispatch({ type: 'toggleExpand', folderId })}>
{folder.isExpanded ? '▼' : '▶'}
</button>
<span>{folder.name}</span>
{#if folder.isExpanded}
<div class="children">
{#each folder.children as child (child.id)}
{#if child.type === 'folder'}
<svelte:self store={store} folderId={child.id} />
{:else}
<File store={store} fileId={child.id} />
{/if}
{/each}
</div>
{/if}
</div>
Key Helpers:
findNode(nodes, id)- Find node by ID (depth-first search)updateNode(nodes, id, updater)- Immutably update nodedeleteNode(nodes, id)- Immutably delete nodeaddChild(nodes, parentId, child)- Add child to parent
Decision: Use tree helpers when:
- Nodes have parent-child relationships
- Structure needs to be traversable (find parent of node)
- Knowing the ID is natural (recursive components)
Avoid: scopeToTreeNode or other complex abstractions - simple helpers provide 80% of value with 20% of complexity.
COMMON ANTI-PATTERNS
1. Component $state for Application State
❌ WRONG
<script lang="ts">
let isEditing = $state(false);
let draftText = $state('');
function save() {
// Can't test this with TestStore
}
</script>
✅ CORRECT
interface State {
isEditing: boolean;
draftText: string;
}
type Action =
| { type: 'startEdit' }
| { type: 'updateDraft'; text: string }
| { type: 'save' };
// Now testable with TestStore
await store.send({ type: 'startEdit' }, (state) => {
expect(state.isEditing).toBe(true);
});
WHY: Component state is not testable with TestStore. All application state must be in store for exhaustive testing.
2. Mutation Instead of Immutable Updates
❌ WRONG
case 'addItem':
state.items.push(action.item); // Mutation!
return [state, Effect.none()];
✅ CORRECT
case 'addItem':
return [
{ ...state, items: [...state.items, action.item] },
Effect.none()
];
WHY: Svelte 5 runes depend on new object references to detect changes. Mutation breaks reactivity.
3. Async Reducer (Side Effects in Reducer)
❌ WRONG
const reducer = async (state, action) => {
const data = await fetch('/api/data');
return [{ ...state, data }, Effect.none()];
};
✅ CORRECT
const reducer = (state, action) => {
return [
{ ...state, isLoading: true },
Effect.run(async (dispatch) => {
const data = await fetch('/api/data');
dispatch({ type: 'dataLoaded', data });
})
];
};
WHY: Reducers must be pure functions. Side effects belong in Effect system.
4. Not Handling API Errors
❌ WRONG
Effect.run(async (dispatch) => {
const result = await api.getData();
dispatch({ type: 'dataLoaded', data: result.data }); // What if result.ok is false?
});
✅ CORRECT
Effect.run(async (dispatch) => {
const result = await api.getData();
if (result.ok) {
dispatch({ type: 'dataLoaded', data: result.data });
} else {
dispatch({ type: 'loadFailed', error: result.error });
}
});
WHY: Always handle both success and error cases for robust applications.
5. Missing Exhaustiveness Check
❌ WRONG
const reducer = (state, action) => {
switch (action.type) {
case 'increment':
return [{ ...state, count: state.count + 1 }, Effect.none()];
// Missing default case - TypeScript won't catch new actions
}
};
✅ CORRECT
const reducer = (state, action) => {
switch (action.type) {
case 'increment':
return [{ ...state, count: state.count + 1 }, Effect.none()];
default:
const _never: never = action; // TypeScript error if action not handled
return [state, Effect.none()];
}
};
WHY: Exhaustiveness check ensures all actions are handled, caught at compile time.
DECISION TOOLS
Composition Strategy Matrix
| Question | Answer | Strategy |
|---|---|---|
| Is child always present? | Yes | scope() |
| Is child optional (navigation)? | Yes | ifLet() + PresentationAction (see composable-svelte-navigation) |
| Is it a list of independent items? | Yes | forEach() + scopeToElement() |
| Multiple slices, same action type? | Yes | combineReducers() |
| Is it a tree structure? | Yes | Helper functions + store + ID |
Effect Type Decision Tree
What kind of side effect?
│
├─ Pure state change (no async, no external calls)
│ └─ Effect.none()
│
├─ Async operation that needs to dispatch back
│ ├─ Single async call → Effect.run()
│ ├─ Fire-and-forget (analytics, logging) → Effect.fireAndForget()
│ └─ Multiple parallel operations → Effect.batch()
│
├─ User input that changes frequently
│ ├─ Search-as-you-type (wait for pause) → Effect.debounced()
│ ├─ Cancel previous request → Effect.cancellable()
│ └─ Limit frequency (scroll, resize) → Effect.throttled()
│
├─ Time-based
│ ├─ Wait then dispatch → Effect.afterDelay()
│ └─ Animation timing → Effect.animated()
│
└─ Long-running connection
└─ WebSocket, SSE, interval → Effect.subscription()
Abstraction Value Matrix (from DESIGN-PRINCIPLES.md)
Core Principle: Apply abstraction where value is high, use simple helpers elsewhere.
| Value | Complexity | Decision |
|---|---|---|
| High | Low | ✅ ADD - Clear win, add to library |
| High | High | ⚠️ CONSIDER - Explore simpler alternatives first |
| Low | Low | ⚠️ MAYBE - Only if it rounds out the API |
| Low | High | ❌ DON'T ADD - Use simple helpers instead |
Examples:
| Pattern | Value | Complexity | Decision |
|---|---|---|---|
forEach for flat collections |
High (92% boilerplate reduction) | Medium (~160 LOC) | ✅ Added |
scopeToElement simplification |
High (80% less boilerplate) | Low (small API change) | ✅ Added |
scopeToTreeNode for trees |
Low (marginal benefit) | High (~500 LOC, 3 concepts) | ❌ Not added |
| Tree helper functions | Medium-High (eliminates recursive boilerplate) | Low (~100 LOC pure functions) | ✅ Added |
Decision Process:
- Value Question: Does this eliminate significant boilerplate? Prevent common bugs? Make code clearer?
- Complexity Question: How many lines? How many new concepts? How hard to debug?
- Trade-off Question: What do we gain? What do we lose? Is it worth it?
- Alternative Question: Could simple helpers achieve 80% of the value?
Key Insight: Better to have 5 powerful abstractions that users love + simple helpers for other cases, than 20 abstractions that cover every case but are hard to learn.
CHECKLIST
Starting New Feature
-
- Define State interface with ALL application state
-
- Define Actions as discriminated union
-
- Define Dependencies interface
-
- Write Reducer as pure function
-
- Use immutable updates (
{ ...state })
- Use immutable updates (
-
- Return Effects as data structures
-
- Add exhaustiveness check in default case
-
- Create TestStore tests (NOT component tests) - See composable-svelte-testing skill
-
- Test all actions with send/receive
-
- Component has NO
$statefor app state
- Component has NO
TEMPLATES
Basic Feature Template
// types.ts
export interface FeatureState {
items: Item[];
isLoading: boolean;
error: string | null;
}
export type FeatureAction =
| { type: 'loadItems' }
| { type: 'itemsLoaded'; items: Item[] }
| { type: 'loadFailed'; error: string };
export interface FeatureDependencies {
api: APIClient;
}
// reducer.ts
import { Reducer, Effect } from '@composable-svelte/core';
export const featureReducer: Reducer<FeatureState, FeatureAction, FeatureDependencies> = (
state,
action,
deps
) => {
switch (action.type) {
case 'loadItems':
return [
{ ...state, isLoading: true, error: null },
Effect.run(async (dispatch) => {
const result = await deps.api.getItems();
if (result.ok) {
dispatch({ type: 'itemsLoaded', items: result.data });
} else {
dispatch({ type: 'loadFailed', error: result.error });
}
})
];
case 'itemsLoaded':
return [{ ...state, items: action.items, isLoading: false }, Effect.none()];
case 'loadFailed':
return [{ ...state, error: action.error, isLoading: false }, Effect.none()];
default:
const _never: never = action;
return [state, Effect.none()];
}
};
// Feature.svelte
<script lang="ts">
import { createStore } from '@composable-svelte/core';
import { featureReducer } from './reducer';
const store = createStore({
initialState: { items: [], isLoading: false, error: null },
reducer: featureReducer,
dependencies: { api: createAPIClient() }
});
$effect(() => {
store.dispatch({ type: 'loadItems' });
});
</script>
{#if $store.isLoading}
<p>Loading...</p>
{:else if $store.error}
<p class="error">{$store.error}</p>
{:else}
<ul>
{#each $store.items as item (item.id)}
<li>{item.name}</li>
{/each}
</ul>
{/if}
Todo with Inline Editing
// State
interface TodoState {
id: string;
text: string;
completed: boolean;
isEditing: boolean; // In store, not component $state
editDraft: string; // In store, not component $state
}
// Actions
type TodoAction =
| { type: 'toggle' }
| { type: 'startEdit' }
| { type: 'updateDraft'; draft: string }
| { type: 'commitEdit' }
| { type: 'cancelEdit' };
// Reducer
case 'startEdit':
return [
{ ...state, isEditing: true, editDraft: state.text },
Effect.none()
];
case 'updateDraft':
return [
{ ...state, editDraft: action.draft },
Effect.none()
];
case 'commitEdit':
return [
{ ...state, text: state.editDraft.trim() || state.text, isEditing: false, editDraft: '' },
Effect.none()
];
case 'cancelEdit':
return [
{ ...state, isEditing: false, editDraft: '' },
Effect.none()
];
Search with Debounce
// State
interface SearchState {
query: string;
results: SearchResult[];
isSearching: boolean;
}
// Actions
type SearchAction =
| { type: 'queryChanged'; query: string }
| { type: 'searchCompleted'; results: SearchResult[] };
// Reducer
case 'queryChanged':
return [
{ ...state, query: action.query, isSearching: true },
Effect.debounced('search', 300, async (dispatch) => {
const results = await api.search(action.query);
dispatch({ type: 'searchCompleted', results });
})
];
case 'searchCompleted':
return [
{ ...state, results: action.results, isSearching: false },
Effect.none()
];
SUMMARY
This skill covers the core architecture patterns for Composable Svelte:
- Critical Rules: All state in store, pragmatic abstraction
- Store-Reducer-Effect Trinity: The fundamental pattern
- 12 Effect Types: Complete effect system with decision tree
- 4 Composition Strategies: scope(), combineReducers(), forEach(), tree helpers
- Anti-Patterns: 5 common mistakes with corrections
- Decision Tools: Matrices and checklists
- Templates: Ready-to-use code patterns
Remember: Testability is the core value. All state in store, test with TestStore (see composable-svelte-testing skill), use the right abstraction for each structure.
For navigation patterns (modals, sheets, drawers), see composable-svelte-navigation skill. For testing patterns, see composable-svelte-testing skill. For forms, see composable-svelte-forms skill. For SSR, see composable-svelte-ssr skill. For component library, see composable-svelte-components skill.