Vue
Integrate StateLoom with a Vue 3 application using the Composition API.
Data Flow
Prerequisites
- Vue 3.2+ (for
onScopeDispose) - Node.js 18+
- Vite 5+ (recommended)
Project Setup
Scaffold a new Vue + Vite project and install StateLoom:
pnpm create vite my-app --template vue-ts
cd my-apppnpm add @stateloom/core @stateloom/vuenpm install @stateloom/core @stateloom/vueyarn add @stateloom/core @stateloom/vueAdd a paradigm adapter:
pnpm add @stateloom/storepnpm add @stateloom/atompnpm add @stateloom/proxyBasic Integration
Signals with useSignal
Bridge StateLoom signals to Vue refs. useSignal returns a read-only Ref<T> that updates automatically when the signal changes:
// src/stores/counter.ts
import { signal, computed, effect } from '@stateloom/core';
export const count = signal(0);
export const doubled = computed(() => count.get() * 2);
export function increment() {
count.update((n) => n + 1);
}
export function decrement() {
count.update((n) => n - 1);
}
export function reset() {
count.set(0);
}<!-- src/components/Counter.vue -->
<script setup lang="ts">
import { useSignal } from '@stateloom/vue';
import { count, doubled, increment, decrement, reset } from '../stores/counter';
const countRef = useSignal(count);
const doubledRef = useSignal(doubled);
</script>
<template>
<div>
<h2>Counter</h2>
<p>
Count: <strong>{{ countRef }}</strong>
</p>
<p>
Doubled: <strong>{{ doubledRef }}</strong>
</p>
<button @click="decrement">-</button>
<button @click="reset">Reset</button>
<button @click="increment">+</button>
</div>
</template>Stores with useStore
Subscribe to stores with optional selectors. The returned ref updates only when the selected value changes:
<script setup lang="ts">
import { createStore } from '@stateloom/store';
import { useStore } from '@stateloom/vue';
const counterStore = createStore((set) => ({
count: 0,
name: 'Alice',
increment: () => set((s) => ({ count: s.count + 1 })),
}));
// With selector: only updates when count changes
const count = useStore(counterStore, (s) => s.count);
</script>
<template>
<div>
<span>{{ count }}</span>
<button @click="counterStore.getState().increment()">+</button>
</div>
</template>Atoms with useAtom
Use atoms for composable, bottom-up state:
// src/stores/user-atoms.ts
import { atom, derived } from '@stateloom/atom';
export const nameAtom = atom('Alice');
export const ageAtom = atom(30);
export const summaryAtom = derived((get) => `${get(nameAtom)}, age ${String(get(ageAtom))}`);<!-- src/components/UserProfile.vue -->
<script setup lang="ts">
import { useSignal } from '@stateloom/vue';
import { nameAtom, ageAtom, summaryAtom } from '../stores/user-atoms';
const name = useSignal(nameAtom);
const age = useSignal(ageAtom);
const summary = useSignal(summaryAtom);
</script>
<template>
<div>
<h2>User Profile</h2>
<label
>Name: <input :value="name" @input="nameAtom.set(($event.target as HTMLInputElement).value)"
/></label>
<label
>Age:
<input
type="number"
:value="age"
@input="ageAtom.set(Number(($event.target as HTMLInputElement).value))"
/></label>
<p>
Summary: <strong>{{ summary }}</strong>
</p>
</div>
</template>Proxy with useSnapshot
For mutable-style state management with Vue:
// src/stores/proxy-state.ts
import { observable } from '@stateloom/proxy';
export const settings = observable({
theme: 'light' as 'light' | 'dark',
fontSize: 16,
notifications: true,
});<!-- src/components/ProxyState.vue -->
<script setup lang="ts">
import { useSignal } from '@stateloom/vue';
import { computed } from '@stateloom/core';
import { snapshot } from '@stateloom/proxy';
import { settings } from '../stores/proxy-state';
const snap = useSignal(computed(() => snapshot(settings)));
</script>
<template>
<div>
<h2>Settings (Proxy)</h2>
<button @click="settings.theme = settings.theme === 'light' ? 'dark' : 'light'">
Toggle Theme
</button>
<button @click="settings.fontSize += 2">Font +</button>
<button @click="settings.fontSize -= 2">Font -</button>
<pre>{{ JSON.stringify(snap, null, 2) }}</pre>
</div>
</template>Vue Plugin
Register the StateLoom Vue plugin for app-wide scope and devtools:
// main.ts
import { createApp } from 'vue';
import { stateloomPlugin } from '@stateloom/vue';
import { createScope } from '@stateloom/core';
import App from './App.vue';
const app = createApp(App);
app.use(stateloomPlugin, { scope: createScope() });
app.mount('#app');After installing the plugin, use useScope() in any component to access the scope:
<script setup lang="ts">
import { useScope } from '@stateloom/vue';
const scope = useScope();
// scope is available for SSR-safe reads
</script>Patterns
Shared Store Composable
Wrap store access in composables for clean component code:
// composables/useCounter.ts
import { createStore } from '@stateloom/store';
import { useStore } from '@stateloom/vue';
const counterStore = createStore((set) => ({
count: 0,
increment: () => set((s) => ({ count: s.count + 1 })),
decrement: () => set((s) => ({ count: s.count - 1 })),
}));
export function useCounter() {
const count = useStore(counterStore, (s) => s.count);
const { increment, decrement } = counterStore.getState();
return { count, increment, decrement };
}<script setup lang="ts">
import { useCounter } from './composables/useCounter';
const { count, increment, decrement } = useCounter();
</script>
<template>
<button @click="decrement">-</button>
<span>{{ count }}</span>
<button @click="increment">+</button>
</template>Middleware Stack
import { createStore } from '@stateloom/store';
import { devtools } from '@stateloom/devtools';
import { persist } from '@stateloom/persist';
const themeStore = createStore(
(set) => ({
theme: 'light' as 'light' | 'dark',
setTheme: (t: 'light' | 'dark') => set({ theme: t }),
}),
{
middleware: [
devtools({ name: 'Theme', enabled: import.meta.env.DEV }),
persist({ key: 'theme-prefs' }),
],
},
);SSR with Nuxt
Scope Isolation
In Nuxt, use the plugin to provide a per-request scope:
// plugins/stateloom.ts
import { defineNuxtPlugin } from '#app';
import { stateloomPlugin } from '@stateloom/vue';
import { createScope } from '@stateloom/core';
export default defineNuxtPlugin((nuxtApp) => {
const scope = createScope();
nuxtApp.vueApp.use(stateloomPlugin, { scope });
});ScopeProvider Component
For more granular control, use ScopeProvider:
<script setup lang="ts">
import { createScope } from '@stateloom/core';
import { ScopeProvider } from '@stateloom/vue';
const scope = createScope();
</script>
<template>
<ScopeProvider :scope="scope">
<slot />
</ScopeProvider>
</template>Migrating from Pinia
If you are coming from Pinia, here is a comparison:
// Pinia
import { defineStore } from 'pinia';
const useCounterStore = defineStore('counter', {
state: () => ({ count: 0 }),
actions: {
increment() {
this.count++;
},
},
});
// StateLoom
import { createStore } from '@stateloom/store';
const counterStore = createStore((set) => ({
count: 0,
increment: () => set((s) => ({ count: s.count + 1 })),
}));
// Use with: useStore(counterStore, (s) => s.count)Key differences:
- No store ID string needed -- stores are identified by reference.
- Actions and state live in the same object.
- Middleware is declarative (array), not plugin-based.
Tips
Composables Pattern
Wrap store access in composables for clean component code. This also makes it easy to switch between paradigms later.
Selector Performance
Always pass a selector to useStore to prevent unnecessary updates:
// Good: only updates when count changes
const count = useStore(store, (s) => s.count);
// Avoid: updates on any state change
const state = useStore(store);Vue Reactivity vs StateLoom Reactivity
useSignal and useStore bridge StateLoom's reactive system to Vue's. Use StateLoom for shared state across components and Vue's ref/reactive for local component state. Do not mix the two for the same piece of state.
Example App
See the complete Vue + Vite example for a working app that demonstrates all paradigms.
Next Steps
- Vue API Reference -- Full composables documentation
- Store API Reference -- Store patterns and middleware
- Getting Started -- Core concepts and paradigm comparison
Live Demo
Try the Vue example directly in your browser: