Skip to content

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 callbackChange callbacksUnsubscribe
Svelte store contractYes (required)YesReturns () => void
StateLoom subscribe()NoYesReturns () => 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>:

typescript
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:

typescript
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:

typescript
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:

VersionMissing context key behavior
Svelte 4getContext() throws an error
Svelte 5getContext() 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.

svelte
<!-- Parent.svelte -->
<script>
  import { createScope } from '@stateloom/core';
  import { setScope } from '@stateloom/svelte';

  const scope = createScope();
  setScope(scope);
</script>

<slot />
svelte
<!-- 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:

svelte
<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

ConcernStrategyCost
Immediate callbackSingle get() call per subscriberO(1) per subscribe
No wrapper overheadPlain object literal — no classes or closures beyond subscribeMinimal allocation
No effect()Direct subscribe delegation — no StateLoom graph overheadZero effect nodes per adapter
$store auto-cleanupSvelte compiler generates unsubscribe callsAutomatic — no manual cleanup
Svelte 4/5 compattry/catch fallback for getContextZero-cost on Svelte 5 (catch not reached)
Adapter size~100 lines totalSmallest adapter

Cross-References