Skip to content

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:

bash
pnpm create vite my-app --template vue-ts
cd my-app
bash
pnpm add @stateloom/core @stateloom/vue
bash
npm install @stateloom/core @stateloom/vue
bash
yarn add @stateloom/core @stateloom/vue

Add a paradigm adapter:

bash
pnpm add @stateloom/store
bash
pnpm add @stateloom/atom
bash
pnpm add @stateloom/proxy

Basic Integration

Signals with useSignal

Bridge StateLoom signals to Vue refs. useSignal returns a read-only Ref<T> that updates automatically when the signal changes:

typescript
// 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);
}
vue
<!-- 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:

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

typescript
// 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))}`);
vue
<!-- 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:

typescript
// src/stores/proxy-state.ts
import { observable } from '@stateloom/proxy';

export const settings = observable({
  theme: 'light' as 'light' | 'dark',
  fontSize: 16,
  notifications: true,
});
vue
<!-- 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:

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

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

typescript
// 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 };
}
vue
<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

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

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

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

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

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

Live Demo

Try the Vue example directly in your browser:

Open in StackBlitz | Open Static Demo