Core architecture patterns for Composable Svelte. Use when creating stores, writing reducers, working with effects, composing reducers, or implementing business logic...
This skill covers the fundamental patterns for building Composable Svelte applications: stores, reducers, effects, and composition strategies.
Principle: Every piece of application state, including UI state, MUST live in the store. This is non-negotiable for testability.
<script lang="ts">
let isEditing = $state(false); // ā Not testable
let draftText = $state(''); // ā Not testable
let showModal = $state(false); // ā Not testable
</script>
// 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?
$state for: Form values, editing flags, draft text, modal open/close, loading states, selected items, expanded/collapsed state$derived for: Computing values from store, filtering/mapping, formatting for displaybind:this), constantsPrinciple: Different data structures need different patterns. Apply abstraction where value is high, use simple helpers elsewhere.
| 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.
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!
<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}
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
const store = createStore({ initialState, reducer, dependencies: {} });
// ā 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.
The fundamental pattern for every feature.
// 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()];
}
};
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}
type field{ ...state, field: newValue })$state for application state$store, dispatches actionsWhat 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()
āā Cancel a presentation's effects ā the navigation operators do it (cancellation groups); by hand: Effect.inGroup() + Effect.cancelGroup()
āā 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()
case 'selectItem':
return [
{ ...state, selectedId: action.id },
Effect.none() // No side effects
];
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 });
}
})
];
case 'buttonClicked':
return [
{ ...state, clickCount: state.clickCount + 1 },
Effect.fireAndForget(async () => {
await analytics.track('button_clicked');
})
];
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 });
})
)
];
case 'searchTextChanged':
return [
{ ...state, searchText: action.text },
Effect.debounced('search', 300, async (dispatch) => {
const results = await api.search(action.text);
dispatch({ type: 'searchResults', results });
})
];
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')
];
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 });
})
];
case 'showToast':
return [
{ ...state, toast: action.message },
Effect.afterDelay(3000, (dispatch) => {
dispatch({ type: 'hideToast' });
})
];
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')
];
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.
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()];
}
};
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
});
When: Independent items that don't know about each other (todo list, product grid)
// State
//
// `forEach` and `forEachElement` address items by id, so the array holds
// `IdentifiedItem<ID, State>` ā `{ id, state }` ā not flat objects that happen
// to carry an `id`. The child reducer then sees only `state`, which is what
// keeps it independent of where the item lives.
interface TodosState {
todos: Array<{ id: string; state: TodoState }>;
}
interface TodoState {
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 forEachElement() - simplified API for standard pattern
import { forEachElement, elementAction } from '@composable-svelte/core';
// forEachElement(actionType, getArray, setArray, childReducer)
// Assumes parent action shape: { type: actionType, id: ID, action: ChildAction }
const todosForEach = forEachElement(
'todo', // action type to match
(s: TodosState) => s.todos, // get array from parent state
(s, todos) => ({ ...s, todos }), // set array in parent state
todoReducer // child reducer
);
// For full control, use forEach(config) with explicit extractChild/wrapChild:
import { forEach } from '@composable-svelte/core';
const todosForEachFull = forEach({
getArray: (s: TodosState) => s.todos,
setArray: (s, todos) => ({ ...s, todos }),
extractChild: (a) => a.type === 'todo' ? { id: a.id, action: a.action } : null,
wrapChild: (id, action) => ({ type: 'todo', id, action }),
childReducer: todoReducer
});
// Helper to create properly-typed element actions:
// elementAction(type, id, action) => { type, id, action }
const action = elementAction('todo', 'todo-1', { type: 'toggle' });
The component. elementAction builds the wrapper the collection reducer above
matches on, so the two stay in step:
{#each $store.todos as todo (todo.id)}
<Todo
todo={todo.state}
onToggle={() => store.dispatch(elementAction('todo', todo.id, { type: 'toggle' }))}
/>
{/each}
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';
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()];
}
};
Folder.svelte:<script lang="ts">
// Runes mode: $props(), not `export let` ā mixing the two is a compile error.
let { store, folderId }: {
store: Store<FileSystemState, FileSystemAction>;
folderId: string;
} = $props();
const folder = $derived(
treeHelpers.findNode(store.state.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 parentDecision: Use tree helpers when:
Avoid: scopeToTreeNode or other complex abstractions - simple helpers provide 80% of value with 20% of complexity.
<script lang="ts">
let isEditing = $state(false);
let draftText = $state('');
function save() {
// Can't test this with TestStore
}
</script>
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.
case 'addItem':
state.items.push(action.item); // Mutation!
return [state, Effect.none()];
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.
const reducer = async (state, action) => {
const data = await fetch('/api/data');
return [{ ...state, data }, Effect.none()];
};
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.
Effect.run(async (dispatch) => {
const result = await api.getData();
dispatch({ type: 'dataLoaded', data: result.data }); // What if result.ok is false?
});
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.
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
}
};
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.
| 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 |
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()
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:
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.
{ ...state })$state for app state// 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()];
}
};
And 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}
// 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()
];
// 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()
];
This skill covers the core architecture patterns for Composable Svelte:
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.