@stateloom/svelte
Svelte adapter for StateLoom. Bridges Subscribable<T> and Signal<T> to Svelte's store contract (Readable/Writable) with scope management for SSR.
Install
pnpm add @stateloom/core @stateloom/sveltenpm install @stateloom/core @stateloom/svelteyarn add @stateloom/core @stateloom/svelteSize: ~0.1 KB gzipped
Overview
The adapter provides two bridge functions (toReadable, toWritable) and scope helpers (setScope, getScope). The bridges satisfy Svelte's store contract so the $ auto-subscription syntax works directly.
Quick Start
<script>
import { signal } from '@stateloom/core';
import { toWritable } from '@stateloom/svelte';
const count = signal(0);
const count$ = toWritable(count);
</script>
<button on:click={() => $count$++}>
Count: {$count$}
</button>Guide
Read-Only Bridging
Use toReadable to bridge any Subscribable<T> (signal, computed, atom, store) to a Svelte Readable:
<script>
import { signal, computed } from '@stateloom/core';
import { toReadable } from '@stateloom/svelte';
const firstName = signal('Alice');
const lastName = signal('Smith');
const fullName = computed(() => `${firstName.get()} ${lastName.get()}`);
const name$ = toReadable(fullName);
</script>
<span>{$name$}</span>Writable Bridging
Use toWritable to bridge a writable Signal<T> to a Svelte Writable. This enables two-way bindings and the $store++ shorthand:
<script>
import { signal } from '@stateloom/core';
import { toWritable } from '@stateloom/svelte';
const name = signal('Alice');
const name$ = toWritable(name);
</script>
<input bind:value={$name$} />
<p>Hello, {$name$}</p>Changes through the Svelte store propagate back to the StateLoom signal, and changes to the signal propagate to the Svelte store.
SSR Scope Management
Use setScope and getScope to manage per-request scope isolation in SvelteKit:
<!-- +layout.svelte -->
<script>
import { createScope } from '@stateloom/core';
import { setScope } from '@stateloom/svelte';
const scope = createScope();
setScope(scope);
</script>
<slot /><!-- +page.svelte -->
<script>
import { getScope } from '@stateloom/svelte';
const scope = getScope();
if (scope) {
// Use scope for SSR-safe reads
}
</script>API Reference
toReadable<T>(subscribable: Subscribable<T>): Readable<T>
Bridge a StateLoom Subscribable<T> to a Svelte-compatible Readable<T> store.
Parameters:
| Parameter | Type | Description | Default |
|---|---|---|---|
subscribable | Subscribable<T> | Any signal, computed, or subscribable from StateLoom. | -- |
Returns: Readable<T> -- a Svelte readable store that mirrors the subscribable.
import { signal } from '@stateloom/core';
import { toReadable } from '@stateloom/svelte';
const count = signal(0);
const count$ = toReadable(count);
const unsub = count$.subscribe((value) => console.log(value));
// Logs: 0 (immediately)
count.set(5);
// Logs: 5
unsub();Key behaviors:
- Calls
run(subscribable.get())immediately on subscription, satisfying Svelte's store contract - Multiple subscribers each receive their own immediate call
- Unsubscribing cleans up the underlying StateLoom subscription
- Works with the
$storeauto-subscription syntax in.sveltefiles
See also: toWritable()
toWritable<T>(signal: Signal<T>): Writable<T>
Bridge a StateLoom Signal<T> to a Svelte-compatible Writable<T> store.
Parameters:
| Parameter | Type | Description | Default |
|---|---|---|---|
signal | Signal<T> | A writable signal from StateLoom. | -- |
Returns: Writable<T> -- a Svelte writable store that mirrors and controls the signal.
import { signal } from '@stateloom/core';
import { toWritable } from '@stateloom/svelte';
const count = signal(0);
const count$ = toWritable(count);
count$.set(10); // sets underlying signal to 10
count$.update((n) => n + 1); // signal is now 11Key behaviors:
set()andupdate()delegate directly to the underlying signal's methods- Changes propagate through the StateLoom reactive graph and then to Svelte subscribers
- The subscribe bridge calls
runimmediately with the current value (Svelte contract) - Equality semantics are inherited from the signal's
equalsfunction - Works with
bind:valueand$storesyntax in.sveltefiles
See also: toReadable()
setScope(scope: Scope): void
Provide a StateLoom scope to descendant Svelte components via Svelte context.
Parameters:
| Parameter | Type | Description | Default |
|---|---|---|---|
scope | Scope | The scope to provide to descendant components. | -- |
Returns: void
<script>
import { createScope } from '@stateloom/core';
import { setScope } from '@stateloom/svelte';
const scope = createScope();
setScope(scope);
</script>
<slot />Key behaviors:
- Must be called during component initialization (not in event handlers or async callbacks)
- Nesting
setScopecalls in child components overrides the scope for that subtree - Uses Svelte's
setContextwith a unique symbol key
See also: getScope()
getScope(): Scope | undefined
Read the StateLoom scope from the nearest ancestor that called setScope.
Parameters: None.
Returns: Scope | undefined -- the current scope, or undefined if none.
<script>
import { getScope } from '@stateloom/svelte';
const scope = getScope();
if (scope) {
// Use scope for SSR-safe reads
}
</script>Key behaviors:
- Must be called during component initialization
- In Svelte 4, catches the error thrown for missing context keys and returns
undefined - In Svelte 5,
getContext()returnsundefinedfor missing keys natively
See also: setScope()
SCOPE_KEY
The unique symbol used as the Svelte context key for scope storage. Exported for advanced use cases; prefer setScope/getScope.
import { SCOPE_KEY } from '@stateloom/svelte';
// Advanced: manual context access
import { setContext } from 'svelte';
import { createScope } from '@stateloom/core';
setContext(SCOPE_KEY, createScope());Patterns
Counter with Two-Way Binding
<script>
import { signal } from '@stateloom/core';
import { toWritable } from '@stateloom/svelte';
const count = signal(0);
const count$ = toWritable(count);
</script>
<input type="number" bind:value={$count$} />
<p>Count is: {$count$}</p>
<button on:click={() => $count$++}>Increment</button>Store with Selector Pattern
Use computed from core to create a derived signal that selects a slice of store state, then bridge it with toReadable:
<script>
import { createStore } from '@stateloom/store';
import { toReadable } from '@stateloom/svelte';
import { computed } from '@stateloom/core';
const store = createStore((set) => ({
count: 0,
name: 'Alice',
increment: () => set((s) => ({ count: s.count + 1 })),
}));
// Use computed to select a slice
const count = computed(() => store.getState().count);
const count$ = toReadable(count);
</script>
<span>Count: {$count$}</span>
<button on:click={() => store.getState().increment()}>+</button>TIP
Unlike React and Vue adapters, Svelte does not have a built-in useStore with selector. Use computed() + toReadable() for the same result.
Atom Integration
Atoms implement Subscribable<T>, so they work directly with toReadable:
<script>
import { atom, derived } from '@stateloom/atom';
import { toReadable, toWritable } from '@stateloom/svelte';
const countAtom = atom(0);
const doubledAtom = derived((get) => get(countAtom) * 2);
const count$ = toWritable(countAtom);
const doubled$ = toReadable(doubledAtom);
</script>
<input type="number" bind:value={$count$} />
<span>{$count$} x 2 = {$doubled$}</span>Batch Updates
<script>
import { signal, batch } from '@stateloom/core';
import { toReadable } from '@stateloom/svelte';
const firstName = signal('Alice');
const lastName = signal('Smith');
const first$ = toReadable(firstName);
const last$ = toReadable(lastName);
function updateName() {
batch(() => {
firstName.set('Bob');
lastName.set('Jones');
});
}
</script>
<span>{$first$} {$last$}</span>
<button on:click={updateName}>Change</button>SvelteKit SSR
<!-- src/routes/+layout.svelte -->
<script>
import { createScope } from '@stateloom/core';
import { setScope } from '@stateloom/svelte';
const scope = createScope();
setScope(scope);
</script>
<slot /><!-- src/routes/+page.svelte -->
<script>
import { getScope } from '@stateloom/svelte';
const scope = getScope();
if (scope) {
// Use scope for SSR-safe reads
}
</script>How It Works
Svelte Store Contract Bridge
Svelte's store contract requires subscribe to invoke the callback immediately with the current value. StateLoom's subscribe() only fires on subsequent changes. The bridge functions close this gap:
For toWritable, the set() and update() methods delegate directly to the signal. Changes flow through the StateLoom reactive graph first, then subscribers are notified:
- Svelte calls
store.set(value) - Delegates to
signal.set(value)(equality check, graph propagation) - Signal notifies subscribers
subscribecallback fires, updating$storein the template
Scope Context
Scope management uses Svelte's built-in setContext/getContext with a unique Symbol key. The getScope function handles both Svelte 4 (which throws for missing keys) and Svelte 5 (which returns undefined) gracefully.
TypeScript
import { signal, computed } from '@stateloom/core';
import { toReadable, toWritable } from '@stateloom/svelte';
import type { Readable, Writable } from 'svelte/store';
import { expectTypeOf } from 'vitest';
// toReadable preserves the subscribable's type
const count = signal(42);
const readable = toReadable(count);
expectTypeOf(readable).toEqualTypeOf<Readable<number>>();
// toWritable preserves the signal's type
const name = signal('Alice');
const writable = toWritable(name);
expectTypeOf(writable).toEqualTypeOf<Writable<string>>();
// Computed types flow through
const doubled = computed(() => count.get() * 2);
const doubledReadable = toReadable(doubled);
expectTypeOf(doubledReadable).toEqualTypeOf<Readable<number>>();Migration
From Svelte's Built-in Stores
Svelte's writable and StateLoom's signal + toWritable are nearly identical:
<script>
// Svelte native
import { writable, derived } from 'svelte/store';
const count = writable(0);
const doubled = derived(count, ($c) => $c * 2);
// StateLoom
import { signal, computed } from '@stateloom/core';
import { toWritable, toReadable } from '@stateloom/svelte';
const countSignal = signal(0);
const doubled = computed(() => countSignal.get() * 2);
const count = toWritable(countSignal);
const doubled$ = toReadable(doubled);
</script>
<!-- Both work with $store syntax -->
<span>{$count} x 2 = {$doubled$}</span>Key differences:
- Svelte stores are Svelte-specific; StateLoom signals are framework-agnostic
- StateLoom's
computed()replaces Svelte'sderived()for cross-framework portability - StateLoom provides middleware (persist, devtools, history) for signals
- The
$storesyntax works identically with both
From Svelte 5 Runes
Svelte 5's $state rune and StateLoom signals serve different scope. Use runes for component-local state and StateLoom for shared, cross-component state:
<script>
// Component-local: use runes
let localCount = $state(0);
// Shared across components/frameworks: use StateLoom
import { signal } from '@stateloom/core';
import { toWritable } from '@stateloom/svelte';
const sharedCount = signal(0);
const count$ = toWritable(sharedCount);
</script>
<span>Local: {localCount}, Shared: {$count$}</span>When to Use
| Scenario | Why @stateloom/svelte |
|---|---|
| Svelte 4 or 5 application | Native $store syntax support |
| Two-way bindings with StateLoom signals | toWritable enables bind:value |
| Atom-based state | toReadable(atom) / toWritable(atom) |
| SvelteKit SSR | setScope/getScope for per-request isolation |
| Shared state across components | StateLoom signals + Svelte stores = reactive bridge |
This is the thinnest adapter (~0.1 KB) because StateLoom's Subscribable is structurally close to Svelte's store contract. The only gap is the immediate invocation on subscribe, which the bridge handles. For React, Solid, Vue, or Angular projects, use the corresponding adapter instead.
See the full Svelte + Vite example app for a complete working application.