Pinia State Management (@ubean/integrations/pinia)
@ubean/integrations/pinia is ubean’s built-in state management integration that integrates Pinia — the official Vue state management library. The module is a thin orchestration layer that wires Pinia’s dev optimization and SSR state hydration into ubean’s Vite pipeline and SSR protocol. Pinia itself is consumed directly from the pinia package.
Features
- One-line enable:
pinia: trueinubean.config.ts - Dev
optimizeDepspre-bundling forpinia— fast first page load in dev - SSR state hydration via
defineApp({ serializeState, hydrateState })hooks - Zero-invasion: Pinia API (
createPinia,defineStore,storeToRefs, …) imported directly frompinia - Type-safe configuration via
UbeanPiniaOptions - Safe degradation: serialization returns
{}when$piniais missing; hydration is a no-op when state isnullor has nopiniafield
Installation
@ubean/integrations/pinia is a built-in integration (a subpath of @ubean/integrations). Install it together with pinia in your project:
pnpm add @ubean/integrations/pinia pinia
piniais a peer dependency — you control its version.@ubean/integrations/piniasupportspinia@^2.0.0 || ^3.0.0.
Configuration
Minimal Setup
The simplest configuration only enables dev pre-bundling optimization:
// ubean.config.ts
import { defineConfig } from 'ubean';
export default defineConfig({
pinia: true
});Then register Pinia and the SSR hydration hooks in src/app.ts:
// src/app.ts
import { createPinia } from 'pinia';
import { serializePiniaState, hydratePiniaState } from '@ubean/integrations';
import { defineApp } from 'ubean';
export default defineApp({
plugins: [createPinia()],
serializeState: serializePiniaState,
hydrateState: hydratePiniaState
});This gives you:
piniapre-bundled in dev for fast first load- Server-side state serialized into the HTML
__UBEAN_STATE__script tag - Client-side hydration before
app.mount()(so stores are populated with SSR state)
Disabling
To disable the module explicitly (same as omitting it):
export default defineConfig({
pinia: false
});
// or
export default defineConfig({
pinia: { disabled: true }
});Disabling dev optimizeDeps
If you use a custom pinia alias or a monorepo-local pinia source, disable pre-bundling:
export default defineConfig({
pinia: { optimizeDeps: false }
});Usage
Defining a Store
Store definitions are identical to regular Pinia:
// src/stores/counter.ts
import { defineStore } from 'pinia';
export const useCounterStore = defineStore('counter', {
state: () => ({
count: 0,
name: 'Counter'
}),
getters: {
double: state => state.count * 2,
// with type inference
doubleCount(): number {
return this.count * 2;
}
},
actions: {
increment() {
this.count++;
},
async fetchInitial() {
const res = await fetch('/api/counter');
this.count = await res.json();
}
}
});Composition API style (setup stores) is also fully supported:
import { ref, computed } from 'vue';
import { defineStore } from 'pinia';
export const useUserStore = defineStore('user', () => {
const name = ref('');
const isAdmin = ref(false);
const displayName = computed(() => name.value || 'Guest');
async function login(email: string, password: string) {
const res = await fetch('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password })
});
const user = await res.json();
name.value = user.name;
isAdmin.value = user.role === 'admin';
}
return { name, isAdmin, displayName, login };
});Using Stores in Components
<script setup lang="ts">
import { storeToRefs } from 'pinia';
import { useCounterStore } from '~/stores/counter';
const store = useCounterStore();
// Destructure with reactivity preserved
const { count, double } = storeToRefs(store);
// Actions can be destructured directly
const { increment } = store;
</script>
<template>
<div>
<p>Count: {{ count }}</p>
<p>Double: {{ double }}</p>
<SButton @click="increment">Increment</SButton>
</div>
</template>Using Stores in API Routes / Loaders
Within ubean’s server-side handlers, you typically don’t share Pinia state across requests — each SSR request creates a fresh app instance. Use Pinia stores inside defineApp’s onAppCreated or within loaders via the SSR app instance.
For page loaders, prefer the loader/action data protocol over Pinia for request-scoped data. Pinia is best suited for client-side shared state (UI state, cached data, user preferences).
How It Works
@ubean/integrations/pinia is a thin wrapper. When pinia: true is set:
Module system loads
@ubean/integrations/piniaand callsubeanPiniaPlugin(options)(whereoptionscomes fromextractBuiltinOptions(config.pinia)— an object is passed through as-is minus the module-systemdisabledflag;trueyields{}).Dev optimizeDeps:
ubeanPiniaPluginaddspiniato Vite’soptimizeDeps.include, ensuring Pinia is pre-bundled for fast first page load in dev. This avoids the dependency scanning delay on the first request.SSR serialization: When
serializePiniaStateis configured asdefineApp({ serializeState }), the ubean SSR renderer calls it afterrenderToString(app)completes. The function readsapp.config.globalProperties.$pinia.state.valueand returns{ pinia: ... }. The renderer serializes this to JSON and injects it into the HTML as<script id="__UBEAN_STATE__" type="application/json">.Client hydration: When
hydratePiniaStateis configured asdefineApp({ hydrateState }), the ubean client entry calls it afterapplyAppConfig(app, config, 'client')(which registerscreatePinia()as a plugin) but beforeapp.mount(). The function reads thepiniafield from the deserialized state and assigns it topinia.state.value.
The hydration must happen before
mount— otherwise stores are already initialized with default state and the hydration is a no-op. ubean’s client entry ensures this ordering.
SSR State Flow
┌─────────────────────────────────────────────────────────────────┐
│ Server │
│ │
│ createSSRApp(initialPage) │
│ applyAppConfig → app.use(createPinia()) │
│ router.push(url) → renderToString(app) │
│ serializeState(app) → { pinia: pinia.state.value } │
│ HTML = shell.replace(STATE_MARKER, <script id=__UBEAN_STATE__>)│
└─────────────────────────────────────────────────────────────────┘
│
▼ (HTML with embedded state)
┌─────────────────────────────────────────────────────────────────┐
│ Client │
│ │
│ createApp() │
│ applyAppConfig → app.use(createPinia()) │
│ state = getInitialState() // read __UBEAN_STATE__ │
│ hydrateState(app, state) → pinia.state.value = state.pinia │
│ app.mount('#app') // stores already hydrated │
└─────────────────────────────────────────────────────────────────┘Programmatic API
import { ubeanPiniaPlugin, definePiniaConfig } from '@ubean/integrations/pinia';
import { serializePiniaState, hydratePiniaState } from '@ubean/integrations';
import type { UbeanPiniaOptions, PiniaSerializedState } from '@ubean/integrations/pinia';ubeanPiniaPlugin(options?: UbeanPiniaOptions): Plugin[]
Returns an array of Vite plugins. Usually called automatically by the module system; call manually only when integrating outside ubean.config.ts.
definePiniaConfig(options: UbeanPiniaOptions): UbeanPiniaOptions
Type-safe helper for authoring UbeanPiniaOptions with autocompletion. Transparently returns the input.
serializePiniaState(app: VueApp): PiniaSerializedState
SSR serialization helper. Reads app.config.globalProperties.$pinia.state.value and returns { pinia: <deep-cloned state> }. Returns {} when $pinia is not detected (e.g., createPinia() not registered) — does not throw.
hydratePiniaState(app: VueApp, state: Record<string, unknown> | null): void
Client hydration helper. Assigns state.pinia to pinia.state.value. No-op when state is null or has no pinia field. Warns to the console when $pinia is not detected on the app (configuration error: hydrateState configured but createPinia() not registered).
UbeanPiniaOptions
export interface UbeanPiniaOptions {
/** Whether the plugin is enabled (default: true). NOTE: in ubean.config.ts
* the module-system option is `disabled?: boolean` instead (inverted). */
enabled?: boolean;
/**
* Whether to add `pinia` to Vite's `optimizeDeps.include` (default: true).
*
* Pre-bundling in dev avoids the dependency scanning delay on first request.
* Disable if you use a custom `pinia` alias or monorepo-local pinia source.
*/
optimizeDeps?: boolean;
}PiniaSerializedState
export interface PiniaSerializedState {
/** Pinia root state (pinia.state.value) */
pinia?: Record<string, unknown>;
/** Allows extending with custom fields */
[key: string]: unknown;
}When configured via
ubean.config.ts, the framework readspinia: true | UbeanPiniaOptions. The module system passes the options directly toubeanPiniaPlugin.
Best Practices
Always pair
pinia: truewith the app.ts hooks:pinia: trueonly enables dev pre-bundling. SSR state hydration requiresserializePiniaState/hydratePiniaStateindefineApp. Without them, SSR-rendered stores reset to defaults on client hydration.One Pinia instance per app: Call
createPinia()once indefineApp({ plugins: [createPinia()] }). State hydration assumes a single$piniaon the app.Keep server-side Pinia state scoped to a single request: Each SSR request creates a fresh Vue app (and thus a fresh Pinia). Keep the Pinia instance local to the request — module-scope instances leak state between requests.
Use loaders for request-scoped data: For data that varies per request (e.g., user-specific data, route params), prefer ubean’s page loader/action protocol. Pinia is best for client-side shared state (UI state, cached data, user preferences) that needs to survive route changes.
Hydrate before mount: The ubean client entry ensures
hydrateStateruns beforeapp.mount(). If you customize the entry, preserve this ordering — otherwise stores initialize with defaults and hydration is lost.Pinia + Islands: Pinia state is available in the main Vue app. Islands (
v-client.load,v-client.idle, etc.) are separate Vue subtrees — they don’t automatically share the main app’s Pinia instance. If an island needs Pinia, install it on the island’s app or pass state via props.
Troubleshooting
Stores reset to defaults after hydration
- Verify
serializePiniaStateandhydratePiniaStateare both configured indefineApp - Check that
createPinia()is indefineApp({ plugins: [...] })(not registered later viaonAppCreated) - Inspect the rendered HTML for
<script id="__UBEAN_STATE__" type="application/json">— it should contain the serialized state - Verify
hydrateStateruns beforeapp.mount()(it does in the default client entry)
Warning: “hydrateState was called but no $pinia was detected on the app”
This means hydratePiniaState was called but createPinia() was not registered as a plugin. Fix:
// src/app.ts
import { createPinia } from 'pinia';
import { hydratePiniaState } from '@ubean/integrations';
export default defineApp({
plugins: [createPinia()], // <-- this is required
hydrateState: hydratePiniaState
});Pinia not pre-bundled in dev
- Verify
pinia: true(orpinia: { ... }) is set inubean.config.ts - Run
ubean devand check Vite’s optimizeDeps output —piniashould appear inoptimizeDeps.include - If using a custom Vite config, ensure
ubeanPiniaPlugin’s output isn’t being filtered out
Type errors for @ubean/integrations
- Run
ubean prepareto regenerate type declarations - Verify
tsconfig.jsonincludes the@ubean/integrations/piniatypes - Ensure you import from
@ubean/integrations(not@ubean/integrations/pinia) for the runtime helpers
Examples
Counter with SSR
<!-- src/pages/counter.vue -->
<script setup lang="ts">
import { storeToRefs } from 'pinia';
import { useCounterStore } from '~/stores/counter';
const store = useCounterStore();
const { count, double } = storeToRefs(store);
</script>
<template>
<div class="flex flex-col items-center gap-4">
<h1>Counter</h1>
<p class="text-4xl font-bold">{{ count }}</p>
<p class="text-sm text-gray-500">Double: {{ double }}</p>
<div class="flex gap-2">
<SButton variant="outline" @click="store.count--">-</SButton>
<SButton @click="store.increment()">+</SButton>
</div>
</div>
</template>// src/stores/counter.ts
import { defineStore } from 'pinia';
export const useCounterStore = defineStore('counter', {
state: () => ({ count: 0 }),
getters: {
double: state => state.count * 2
},
actions: {
increment() {
this.count++;
}
}
});The counter state survives navigation and SSR hydration — no extra setup needed.
Theme Toggle
// src/stores/theme.ts
import { defineStore } from 'pinia';
export const useThemeStore = defineStore('theme', {
state: () => ({
mode: 'light' as 'light' | 'dark'
}),
actions: {
toggle() {
this.mode = this.mode === 'light' ? 'dark' : 'light';
}
}
});<!-- src/layouts/default.vue -->
<script setup lang="ts">
import { storeToRefs } from 'pinia';
import { useThemeStore } from '~/stores/theme';
const theme = useThemeStore();
const { mode } = storeToRefs(theme);
</script>
<template>
<div :class="mode">
<header>
<SButton variant="ghost" size="sm" @click="theme.toggle()">
{{ mode === 'light' ? '🌙' : '☀️' }}
</SButton>
</header>
<main><slot /></main>
</div>
</template>