@stateloom/vue
Vue 3 adapter for StateLoom — composables bridging reactive signals to Vue refs.
Install
pnpm add @stateloom/vue @stateloom/corenpm install @stateloom/vue @stateloom/coreyarn add @stateloom/vue @stateloom/coreSize: ~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
<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>:
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:
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:
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:
// 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:
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:
<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:
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:
| Parameter | Type | Description | Default |
|---|---|---|---|
source | Subscribable<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.
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 === 5Key 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
onScopeDisposeon 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:
| Parameter | Type | Description | Default |
|---|---|---|---|
store | Subscribable<T> | The store or subscribable to bridge | — |
selector | (state: T) => U | Extract a derived value from the full state | (s) => s |
equals | (a: U, b: U) => boolean | Custom equality for the selected value | Object.is |
Returns: Readonly<Ref<T>> (no selector) or Readonly<Ref<U>> (with selector).
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.isby default; passequalsfor 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:
| Parameter | Type | Description | Default |
|---|---|---|---|
scope | Scope | The scope to provide | — |
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.
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:
| Prop | Type | Required | Description |
|---|---|---|---|
scope | Scope | Yes | The scope to provide |
<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.
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:
| Option | Type | Description |
|---|---|---|
scope | Scope | undefined | SSR scope available via useScope() |
See also: useScope()
StateloomPluginOptions
Options for the Vue plugin.
interface StateloomPluginOptions {
readonly scope?: Scope;
}How It Works
The Vue adapter bridges StateLoom's reactive graph to Vue's reactivity system:
useSignalcreates ashallowRefand a StateLoomeffect()- The effect reads
source.get(), establishing a dependency in the StateLoom graph - When the source changes, the effect re-runs synchronously and updates the ref
- Vue's reactivity system picks up the ref change and updates the template
- On component unmount,
onScopeDisposecallsdispose()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
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:
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 === 10Proxy Integration
Use @stateloom/proxy's subscribe and snapshot with Vue's shallowRef:
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:
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
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
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:
// 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 changesKey differences:
- Pinia stores are Vue-specific; StateLoom stores are framework-agnostic
- Pinia uses
defineStorewith options/setup syntax; StateLoom usescreateStorewith a callback - Pinia returns a reactive object; StateLoom's
useStorereturns aRefviashallowRef - StateLoom supports selectors for fine-grained subscriptions out of the box
From VueUse State
// 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
| Scenario | Use |
|---|---|
| Single signal value in a component | useSignal(signal) |
| Computed/derived value in a component | useSignal(computed) |
| Store state with selector | useStore(store, selector) |
| Full store state | useStore(store) |
| Atom value in a component | useSignal(atom) |
| App-level SSR scope | stateloomPlugin with scope option |
| Component-level scope | provideScope() or ScopeProvider |
See the full Vue + Vite example app for a complete working application.