Svelte Adapter Design
Low-level design for @stateloom/svelte — the Svelte framework adapter. Covers the Svelte store contract bridging, toReadable/toWritable implementations, and SSR scope integration via Svelte's context API with Svelte 4/5 compatibility.
Overview
The Svelte adapter bridges Subscribable<T> from @stateloom/core to Svelte's native store contract. Unlike other adapters that use framework-specific reactive primitives (refs, signals), the Svelte adapter returns plain objects that satisfy the Readable<T> and Writable<T> interfaces from svelte/store. This enables direct use of Svelte's $store auto-subscription syntax. The adapter is the simplest of all adapters (~100 lines total).
Bridging Strategy: Store Contract Alignment
Svelte's store contract is simple: any object with a subscribe method that (1) calls its callback immediately with the current value, and (2) returns an unsubscribe function. StateLoom's subscribe() differs — it only fires on subsequent changes, not on the initial call. The adapter bridges this gap.
Subscribe Contract Difference
| Immediate callback | Change callbacks | Unsubscribe | |
|---|---|---|---|
| Svelte store contract | Yes (required) | Yes | Returns () => void |
| StateLoom subscribe() | No | Yes | Returns () => void |
The adapter bridges this by calling run(subscribable.get()) synchronously before delegating to subscribable.subscribe(run).
Implementation Details
toReadable
Converts any Subscribable<T> to a Svelte Readable<T>:
export function toReadable<T>(subscribable: Subscribable<T>): Readable<T> {
return {
subscribe(run: (value: T) => void): () => void {
run(subscribable.get()); // immediate call (Svelte requirement)
return subscribable.subscribe(run); // future changes
},
};
}Immediate call: run(subscribable.get()) fires synchronously before returning. This satisfies Svelte's expectation that subscribers receive the current value immediately. Without this, the $store syntax would show undefined until the first state change.
No wrapper or caching: The returned object is a thin wrapper — no caching, no equality checks. Each subscribe call creates a fresh subscription. This is correct because Svelte manages subscription lifecycle via the $store syntax (auto-subscribe on mount, auto-unsubscribe on destroy).
Multiple subscribers: Each subscriber receives its own immediate call with the current value. This matches Svelte's multi-subscriber model where components independently subscribe and unsubscribe.
toWritable
Extends the subscribe bridging with set and update methods:
export function toWritable<T>(signal: Signal<T>): Writable<T> {
return {
subscribe(run: (value: T) => void): () => void {
run(signal.get());
return signal.subscribe(run);
},
set(value: T): void {
signal.set(value);
},
update(fn: (current: T) => T): void {
signal.update(fn);
},
};
}set() and update() delegate directly: No wrapping, no interception. Changes flow through the StateLoom signal's reactive graph, which triggers subscribe callbacks, which Svelte picks up via its $store mechanism.
Two-way binding: The returned Writable<T> works with Svelte's bind:value={$store} syntax. When the user types into an input, Svelte calls store.set(newValue), which updates the StateLoom signal, which notifies subscribers, which updates the component.
Why toReadable Accepts Subscribable but toWritable Accepts Signal
toReadable works with any Subscribable<T> — signals, computed values, stores, atoms, or custom implementations. All it needs is get() and subscribe().
toWritable requires a Signal<T> because it needs set() and update() methods. Computed values and stores don't expose these methods (stores use setState), so they can only be bridged as readable stores.
SSR Scope Integration
The Svelte adapter uses Svelte's setContext/getContext for scope injection:
const SCOPE_KEY: unique symbol = Symbol('stateloom-scope');
function setScope(scope: Scope): void {
setContext(SCOPE_KEY, scope);
}
function getScope(): Scope | undefined {
try {
return getContext<Scope>(SCOPE_KEY);
} catch {
return undefined;
}
}Svelte 4/5 Compatibility
The try/catch in getScope handles a behavioral difference between Svelte versions:
| Version | Missing context key behavior |
|---|---|
| Svelte 4 | getContext() throws an error |
| Svelte 5 | getContext() returns undefined |
The try/catch handles Svelte 4's throwing behavior. In Svelte 5, the catch block is never reached — getContext returns undefined directly. This pattern ensures the adapter works with both versions without requiring version detection.
Why setContext Instead of a Component
Svelte's context system is function-based (setContext/getContext) rather than component-based (like React's Context.Provider). The Svelte adapter follows this convention — setScope is called in the parent component's <script> block, and getScope is called in any descendant. No wrapper component is needed.
<!-- Parent.svelte -->
<script>
import { createScope } from '@stateloom/core';
import { setScope } from '@stateloom/svelte';
const scope = createScope();
setScope(scope);
</script>
<slot /><!-- Child.svelte -->
<script>
import { getScope } from '@stateloom/svelte';
const scope = getScope();
</script>Why SCOPE_KEY Is Exported
The SCOPE_KEY symbol is exported for advanced use cases — testing, manual context manipulation, or framework-level integrations (e.g., SvelteKit hooks). Most consumers use setScope/getScope and never interact with the key directly.
Design Decisions
Why the Native Store Contract
Svelte's $store syntax works with any object that has a subscribe method matching the store contract. By returning objects that satisfy Readable<T> and Writable<T>, StateLoom signals work directly with Svelte's template syntax — no special compiler support, no runtime library, no custom actions. This is the most idiomatic integration possible for Svelte.
Why No effect() Like Vue and Solid
The Svelte adapter doesn't use StateLoom's effect(). Svelte's $store syntax handles subscription lifecycle automatically — it subscribes on mount and unsubscribes on destroy. Since toReadable/toWritable delegate directly to subscribable.subscribe(), the subscription is already integrated with StateLoom's notification system.
For computed signals, this means the Svelte adapter relies on subscribe() alone. This works because computed signals fire their subscribe callbacks when their value changes — the pull-based refresh happens internally when the computed detects dirty dependencies. Unlike Vue and Solid, the Svelte adapter doesn't need effect() to trigger this refresh, because Svelte's $store mechanism calls get() (via the immediate callback) which triggers the refresh.
Why No Selector Support
Unlike Vue and Solid's useStore, the Svelte adapter doesn't provide selector memoization. Svelte's reactivity model is different — instead of hook-based selectors, Svelte uses reactive declarations:
<script>
import { toReadable } from '@stateloom/svelte';
const store$ = toReadable(store);
// Svelte's built-in reactivity handles derived values
$: count = $store$.count;
$: doubled = count * 2;
</script>Reactive declarations ($:) in Svelte 4 and $derived in Svelte 5 provide built-in memoization. Adding a selector API would be redundant with Svelte's native capabilities.
Why unique symbol for SCOPE_KEY
Using unique symbol (TypeScript's branded symbol type) ensures the key cannot collide with any other context key. The unique symbol type also prevents accidental use of a different symbol with the same description string.
Performance Considerations
| Concern | Strategy | Cost |
|---|---|---|
| Immediate callback | Single get() call per subscriber | O(1) per subscribe |
| No wrapper overhead | Plain object literal — no classes or closures beyond subscribe | Minimal allocation |
| No effect() | Direct subscribe delegation — no StateLoom graph overhead | Zero effect nodes per adapter |
| $store auto-cleanup | Svelte compiler generates unsubscribe calls | Automatic — no manual cleanup |
| Svelte 4/5 compat | try/catch fallback for getContext | Zero-cost on Svelte 5 (catch not reached) |
| Adapter size | ~100 lines total | Smallest adapter |
Cross-References
- Adapters Overview — cross-cutting adapter patterns and adapter contract
- Architecture Overview — where adapters fit in the layer structure
- Core Design —
Subscribable<T>contract thattoReadable/toWritablebridge - Store Design —
StoreApi<T>bridgeable viatoReadable - Atom Design — atom APIs bridgeable via
toReadable - Proxy Design — proxy APIs bridgeable via
toReadable - API Reference:
@stateloom/svelte— consumer-facing documentation - Svelte Guide — framework adoption guide