Skip to content

@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

bash
pnpm add @stateloom/core @stateloom/svelte
bash
npm install @stateloom/core @stateloom/svelte
bash
yarn add @stateloom/core @stateloom/svelte

Size: ~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

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

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

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

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

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

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

ParameterTypeDescriptionDefault
subscribableSubscribable<T>Any signal, computed, or subscribable from StateLoom.--

Returns: Readable<T> -- a Svelte readable store that mirrors the subscribable.

typescript
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 $store auto-subscription syntax in .svelte files

See also: toWritable()


toWritable<T>(signal: Signal<T>): Writable<T>

Bridge a StateLoom Signal<T> to a Svelte-compatible Writable<T> store.

Parameters:

ParameterTypeDescriptionDefault
signalSignal<T>A writable signal from StateLoom.--

Returns: Writable<T> -- a Svelte writable store that mirrors and controls the signal.

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

Key behaviors:

  • set() and update() delegate directly to the underlying signal's methods
  • Changes propagate through the StateLoom reactive graph and then to Svelte subscribers
  • The subscribe bridge calls run immediately with the current value (Svelte contract)
  • Equality semantics are inherited from the signal's equals function
  • Works with bind:value and $store syntax in .svelte files

See also: toReadable()


setScope(scope: Scope): void

Provide a StateLoom scope to descendant Svelte components via Svelte context.

Parameters:

ParameterTypeDescriptionDefault
scopeScopeThe scope to provide to descendant components.--

Returns: void

svelte
<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 setScope calls in child components overrides the scope for that subtree
  • Uses Svelte's setContext with 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.

svelte
<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() returns undefined for 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.

typescript
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

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

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

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

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

svelte
<!-- src/routes/+layout.svelte -->
<script>
  import { createScope } from '@stateloom/core';
  import { setScope } from '@stateloom/svelte';

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

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

  1. Svelte calls store.set(value)
  2. Delegates to signal.set(value) (equality check, graph propagation)
  3. Signal notifies subscribers
  4. subscribe callback fires, updating $store in 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

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:

svelte
<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's derived() for cross-framework portability
  • StateLoom provides middleware (persist, devtools, history) for signals
  • The $store syntax 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:

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

ScenarioWhy @stateloom/svelte
Svelte 4 or 5 applicationNative $store syntax support
Two-way bindings with StateLoom signalstoWritable enables bind:value
Atom-based statetoReadable(atom) / toWritable(atom)
SvelteKit SSRsetScope/getScope for per-request isolation
Shared state across componentsStateLoom 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.