Skip to content

@stateloom/vue

Vue 3 adapter for StateLoom — composables bridging reactive signals to Vue refs.

Install

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

Size: ~1.5 KB gzipped

Requires: Vue 3.2+ (for onScopeDispose)

Optional: Store Package

Add @stateloom/store for useStore with selector memoization. Atoms and proxy work through useSignal without additional dependencies since they implement Subscribable<T>.

Overview

The Vue adapter provides composables that bridge StateLoom's Subscribable<T> interface to Vue's reactive Ref<T>. It uses effect() from @stateloom/core for full dependency graph integration, ensuring both signals and computed values propagate correctly.

Quick Start

typescript
<script setup>
import { signal, computed } from '@stateloom/core';
import { useSignal } from '@stateloom/vue';

const count = signal(0);
const doubled = computed(() => count.get() * 2);

const countRef = useSignal(count);
const doubledRef = useSignal(doubled);

function increment() {
  count.set(count.get() + 1);
}
</script>

<template>
  <button @click="increment">
    {{ countRef }} x2 = {{ doubledRef }}
  </button>
</template>

Guide

Bridging Signals to Vue

useSignal takes any Subscribable<T> and returns a read-only Vue Ref<T>:

typescript
import { signal, computed } from '@stateloom/core';
import { useSignal } from '@stateloom/vue';

// In setup()
const name = signal('Alice');
const greeting = computed(() => `Hello, ${name.get()}!`);

const nameRef = useSignal(name); // Ref<string>
const greetingRef = useSignal(greeting); // Ref<string>

// Refs update automatically when signals change
name.set('Bob');
// nameRef.value === 'Bob'
// greetingRef.value === 'Hello, Bob!'

The ref is read-only at the type level — update state through the signal, not the ref.

Using Stores with Selectors

useStore adds selector memoization for efficient store integration:

typescript
import { createStore } from '@stateloom/store';
import { useStore } from '@stateloom/vue';

const store = createStore((set) => ({
  count: 0,
  name: 'Alice',
  increment: () => set((s) => ({ count: s.count + 1 })),
  setName: (n: string) => set({ name: n }),
}));

// Full state (re-renders on any change)
const state = useStore(store);

// Selected slice (re-renders only when count changes)
const count = useStore(store, (s) => s.count);

TIP

Always use a selector when you only need part of the state. This prevents unnecessary Vue re-renders when unrelated state changes.

Custom Equality

Pass a custom equality function to control when the ref updates:

typescript
const items = useStore(
  store,
  (s) => s.items,
  (a, b) => a.length === b.length, // only update when length changes
);

SSR Scope Integration

For SSR, provide a scope to isolate state between requests:

typescript
// main.ts (server)
import { createApp } from 'vue';
import { createScope } from '@stateloom/core';
import { stateloomPlugin } from '@stateloom/vue';

const app = createApp(App);
const scope = createScope();
app.use(stateloomPlugin, { scope });
app.mount('#app');

Access the scope in any component:

typescript
import { useScope } from '@stateloom/vue';

// In setup()
const scope = useScope();
if (scope) {
  const value = scope.get(someSignal);
}

ScopeProvider Component

For declarative scope boundaries in templates:

vue
<template>
  <ScopeProvider :scope="childScope">
    <ChildComponent />
  </ScopeProvider>
</template>

<script setup>
import { createScope } from '@stateloom/core';
import { ScopeProvider } from '@stateloom/vue';

const childScope = createScope();
</script>

Composable Scope Injection

Use provideScope in a parent's setup to inject scope for descendants:

typescript
import { createScope } from '@stateloom/core';
import { provideScope, useScope } from '@stateloom/vue';

// Parent component
function setup() {
  const scope = createScope();
  provideScope(scope);
}

// Child component (any depth)
function setup() {
  const scope = useScope(); // receives parent's scope
}

API Reference

useSignal<T>(source: Subscribable<T>): Readonly<Ref<T>>

Bridge a StateLoom subscribable to a Vue ref.

Parameters:

ParameterTypeDescriptionDefault
sourceSubscribable<T>Any reactive source — signal, computed, store, or atom

Returns: Readonly<Ref<T>> — a read-only Vue ref that stays in sync with the source.

typescript
import { signal } from '@stateloom/core';
import { useSignal } from '@stateloom/vue';

const count = signal(0);
const countRef = useSignal(count);
// countRef.value === 0

count.set(5);
// countRef.value === 5

Key behaviors:

  • Uses effect() from core for full dependency graph integration
  • Works with both signals and computed (not just subscribe-based)
  • Uses shallowRef — nested objects are not deeply reactive
  • Auto-cleanup via onScopeDispose on component unmount
  • Must be called inside setup() or a Vue effect scope

See also: useStore()


useStore<T>(store: Subscribable<T>): Readonly<Ref<T>>

useStore<T, U>(store: Subscribable<T>, selector: (state: T) => U, equals?: (a: U, b: U) => boolean): Readonly<Ref<U>>

Bridge a store to a Vue ref with optional selector memoization.

Parameters:

ParameterTypeDescriptionDefault
storeSubscribable<T>The store or subscribable to bridge
selector(state: T) => UExtract a derived value from the full state(s) => s
equals(a: U, b: U) => booleanCustom equality for the selected valueObject.is

Returns: Readonly<Ref<T>> (no selector) or Readonly<Ref<U>> (with selector).

typescript
import { createStore } from '@stateloom/store';
import { useStore } from '@stateloom/vue';

const store = createStore((set) => ({
  count: 0,
  name: 'Alice',
  increment: () => set((s) => ({ count: s.count + 1 })),
}));

// Full state
const state = useStore(store);

// Selected slice
const count = useStore(store, (s) => s.count);

// Custom equality
const items = useStore(
  store,
  (s) => s.items,
  (a, b) => a.length === b.length,
);

Key behaviors:

  • Without a selector, equivalent to useSignal(store)
  • With a selector, only updates the ref when the selected value changes
  • Uses Object.is by default; pass equals for custom comparison
  • Uses effect() for full graph integration — selector dependencies are tracked
  • Auto-cleanup via onScopeDispose
  • Must be called inside setup() or a Vue effect scope

See also: useSignal()


provideScope(scope: Scope): void

Provide a StateLoom scope to all descendant components.

Parameters:

ParameterTypeDescriptionDefault
scopeScopeThe scope to provide
typescript
import { createScope } from '@stateloom/core';
import { provideScope } from '@stateloom/vue';

// In setup()
const scope = createScope();
provideScope(scope);

Key behaviors:

  • Must be called inside setup()
  • Scope is static — does not update reactively
  • Children access it via useScope()

See also: useScope(), ScopeProvider


useScope(): Scope | undefined

Retrieve the nearest StateLoom scope from the component hierarchy.

Returns: Scope | undefined — the nearest provided scope, or undefined if none exists.

typescript
import { useScope } from '@stateloom/vue';

// In setup()
const scope = useScope();
if (scope) {
  const value = scope.get(mySignal);
}

See also: provideScope(), ScopeProvider


ScopeProvider

Renderless component that provides a scope to its children via Vue's provide/inject.

Props:

PropTypeRequiredDescription
scopeScopeYesThe scope to provide
vue
<template>
  <ScopeProvider :scope="myScope">
    <ChildComponent />
  </ScopeProvider>
</template>

Key behaviors:

  • Renders only default slot children (no wrapper element)
  • Scope is provided once during setup
  • Equivalent to calling provideScope() in a parent component

See also: provideScope(), useScope()


stateloomPlugin

Vue plugin for app-level scope injection.

typescript
import { createApp } from 'vue';
import { createScope } from '@stateloom/core';
import { stateloomPlugin } from '@stateloom/vue';

const app = createApp(App);

// Without scope (client-side only)
app.use(stateloomPlugin);

// With SSR scope
app.use(stateloomPlugin, { scope: createScope() });

Options:

OptionTypeDescription
scopeScope | undefinedSSR scope available via useScope()

See also: useScope()


StateloomPluginOptions

Options for the Vue plugin.

typescript
interface StateloomPluginOptions {
  readonly scope?: Scope;
}

How It Works

The Vue adapter bridges StateLoom's reactive graph to Vue's reactivity system:

  1. useSignal creates a shallowRef and a StateLoom effect()
  2. The effect reads source.get(), establishing a dependency in the StateLoom graph
  3. When the source changes, the effect re-runs synchronously and updates the ref
  4. Vue's reactivity system picks up the ref change and updates the template
  5. On component unmount, onScopeDispose calls dispose() to remove the effect from the graph

This approach uses effect() rather than subscribe() to ensure computed signals propagate correctly through the dependency graph.

TypeScript

typescript
import { signal, computed } from '@stateloom/core';
import { useSignal, useStore } from '@stateloom/vue';
import type { Ref } from 'vue';

// Type is inferred from the signal
const count = signal(42);
const ref = useSignal(count);
// ref: Readonly<Ref<number>>

// Type is inferred from the selector
const store = createStore(() => ({ count: 0, name: 'test' }));
const countRef = useStore(store, (s) => s.count);
// countRef: Readonly<Ref<number>>

// Full state type is inferred
const stateRef = useStore(store);
// stateRef: Readonly<Ref<{ count: number; name: string }>>

Patterns

Atom Integration

Atoms implement Subscribable<T>, so they work directly with useSignal:

typescript
import { atom, derived } from '@stateloom/atom';
import { useSignal } from '@stateloom/vue';

const countAtom = atom(0);
const doubledAtom = derived((get) => get(countAtom) * 2);

// In setup()
const count = useSignal(countAtom);
const doubled = useSignal(doubledAtom);
// count.value === 0, doubled.value === 0

countAtom.set(5);
// count.value === 5, doubled.value === 10

Proxy Integration

Use @stateloom/proxy's subscribe and snapshot with Vue's shallowRef:

typescript
import { observable, snapshot, subscribe } from '@stateloom/proxy';
import { shallowRef, onScopeDispose } from 'vue';

const state = observable({ count: 0, user: { name: 'Alice' } });

// In setup()
const snap = shallowRef(snapshot(state));
const unsub = subscribe(state, () => {
  snap.value = snapshot(state);
});
onScopeDispose(unsub);

// Template: {{ snap.user.name }}: {{ snap.count }}
// Mutate directly: state.count++

Computed Derived State

Use computed from core with useSignal for derived values:

typescript
import { signal, computed, batch } from '@stateloom/core';
import { useSignal } from '@stateloom/vue';

const firstName = signal('Alice');
const lastName = signal('Smith');
const fullName = computed(() => `${firstName.get()} ${lastName.get()}`);

// In setup()
const name = useSignal(fullName);
// name.value === 'Alice Smith'

// Batch updates to avoid intermediate updates
batch(() => {
  firstName.set('Bob');
  lastName.set('Jones');
});
// name.value === 'Bob Jones'

Store with Actions

typescript
import { createStore } from '@stateloom/store';
import { useStore } from '@stateloom/vue';

const todoStore = createStore((set, get) => ({
  todos: [] as string[],
  add: (text: string) => set({ todos: [...get().todos, text] }),
  remove: (index: number) =>
    set({
      todos: get().todos.filter((_, i) => i !== index),
    }),
}));

// In setup()
const todos = useStore(todoStore, (s) => s.todos);
const todoCount = useStore(todoStore, (s) => s.todos.length);

Multiple Stores

typescript
const userStore = createStore((set) => ({
  name: 'Alice',
  setName: (n: string) => set({ name: n }),
}));

const settingsStore = createStore((set) => ({
  theme: 'dark' as 'dark' | 'light',
  toggleTheme: () =>
    set((s) => ({
      theme: s.theme === 'dark' ? 'light' : 'dark',
    })),
}));

// In setup()
const userName = useStore(userStore, (s) => s.name);
const theme = useStore(settingsStore, (s) => s.theme);

Migration

From Pinia

Pinia stores and @stateloom/vue share similar composable patterns:

typescript
// Pinia
import { defineStore } from 'pinia'; 
const useCounterStore = defineStore('counter', {
  state: () => ({ count: 0 }), 
  actions: {
    increment() {
      this.count++;
    }, 
  }, 
}); 

// In setup()
const store = useCounterStore(); 
// store.count, store.increment()

// StateLoom
import { createStore } from '@stateloom/store'; 
import { useStore } from '@stateloom/vue'; 
const counterStore = createStore((set) => ({
  count: 0, 
  increment: () => set((s) => ({ count: s.count + 1 })), 
})); 

// In setup()
const count = useStore(counterStore, (s) => s.count); 
// Ref<number> — only updates when count changes

Key differences:

  • Pinia stores are Vue-specific; StateLoom stores are framework-agnostic
  • Pinia uses defineStore with options/setup syntax; StateLoom uses createStore with a callback
  • Pinia returns a reactive object; StateLoom's useStore returns a Ref via shallowRef
  • StateLoom supports selectors for fine-grained subscriptions out of the box

From VueUse State

typescript
// VueUse
import { useStorage } from '@vueuse/core'; 
const count = useStorage('count', 0); 

// StateLoom (with persist middleware)
import { signal } from '@stateloom/core'; 
import { persist } from '@stateloom/persist'; 
import { useSignal } from '@stateloom/vue'; 
const count = signal(0); 
persist(count, { key: 'count' }); 
const countRef = useSignal(count); 

When to Use

ScenarioUse
Single signal value in a componentuseSignal(signal)
Computed/derived value in a componentuseSignal(computed)
Store state with selectoruseStore(store, selector)
Full store stateuseStore(store)
Atom value in a componentuseSignal(atom)
App-level SSR scopestateloomPlugin with scope option
Component-level scopeprovideScope() or ScopeProvider

See the full Vue + Vite example app for a complete working application.