Pinia Store Alternative
A class-based alternative to Pinia stores.
A global store needs three things: shared state, derived values, and actions. An ivue class already is all three — so the entire store machinery reduces to one static on the class that owns the singleton:
class $ProjectStore {
protected static get $shared(): ProjectStore.Instance {
return new ProjectStore.Class();
}
static use(): ProjectStore.Instance {
return this.$shared;
}
// …state, derivations, actions
}
export namespace ProjectStore {
export const $Class = Static($ProjectStore); // anchor — it declares statics
export let Class = Reactive($Class); // reactive — you `new` this
export type Instance = typeof Class.Instance;
}No Pinia, no defineStore, no plugin registration, and nothing in the namespace but identity and types. Every component that calls ProjectStore.Class.use() receives the same instance; state written in one panel renders in every other. A $-static runs its body once, on first touch, after the app exists, so module-load order and circular imports stay non-events. It constructs through the namespace slot, so a test double swapped into Class is what gets built, and since every consumer reaches the store through that one slot, the per-receiver cache never forks.
Three independent components below share the store with zero props between them — type a task in the first panel and watch the other two react:
Open in StackBlitz ⚡ — boots the playground on this example's route with the store class open.
Injecting the store into other classes
Components call use() directly in setup. Other classes — view models, entities, capability classes — reach the store through a cached $-getter instead of receiving it as a constructor argument:
class $TaskBoardModel {
// resolved on first touch, cached per instance, circular-import safe
protected get $project() {
return ProjectStore.Class.use();
}
get remainingLabel() {
return `${this.$project.visibleTasks.length} tasks in view`;
}
completeAll() {
for (const task of this.$project.visibleTasks) {
this.$project.toggleTask(task.id);
}
}
}This is what keeps shared state out of prop chains: no <ChildView :store="store" />, no constructor(public store: …) threading one object through every signature it crosses. A prop named store, app, or session is the tell that a store is being drilled — pass props for genuinely per-instance input (a row, a slug, a config knob), and reach for the store for what is genuinely shared.
Tests swap the seam, not the callers: install a double with ProjectStore.Class = Reactive($TestProjectStore) before the first use() and every consumer receives it through the same getter.
The optional reactive() view
Some teams prefer store reads without .value. A store can publish itself as a reactive() view instead — refs auto-unwrap on read and write — and use() stays the one door: it returns the view. In full:
// ProjectStore.ts — the same store, published as a reactive() view
import { reactive, ref } from 'vue';
import { Reactive } from 'ivue';
import { Static } from 'ivue/extras';
class $ProjectStore {
/** The ONE instance, as a reactive() view — built once, on first read,
* through the namespace slot. */
protected static get $sharedReactive() {
return reactive(new ProjectStore.Class() as ProjectStore.Instance);
}
/** The store singleton: every caller receives the SAME view. */
static use() {
return this.$sharedReactive;
}
get projectName() {
return ref('Untitled');
}
get filter() {
return ref<ProjectStore.TaskFilter>('all');
}
setFilter(filter: ProjectStore.TaskFilter) {
this.filter.value = filter;
}
}
export namespace ProjectStore {
export const $Class = Static($ProjectStore); // anchor — it declares statics
export let Class = Reactive($Class); // reactive — use() does the one `new`
export type Instance = typeof Class.Instance;
export type TaskFilter = 'all' | 'active' | 'done';
}const project = ProjectStore.Class.use();
project.projectName = 'Artemis'; // ref write, no .value
project.filter = 'done'; // typechecks: Instance strips the readonlyInside the class, cells are still refs and methods still write .value; only what use() hands out changes. The Instance type under the view is load-bearing: it strips the readonly TypeScript puts on get-only accessors, so writes typecheck exactly as they behave at runtime (the unwrapping-surface invariant).
What to notice
- The store outlives components, so its constructor uses
this.$watchEffect(the instance's own effect scope), not plainwatchEffect— the lifecycle rule for outliving instances. - Derivations are plain getters (
completedCount,progressPercent,visibleTasks) — every consumer reads live values, zero computeds allocated. - The third panel writes
projectNameandfilteras state bindings — the same cells the first panel'saddTask()and the store's ownpersist()read, so every panel re-renders from one write.
Related guide pages
- Composables & Stores — hosting and publishing composables; stores behind
use(). - Lifecycle & Teardown — the two lifetimes,
$stopEffects, the bridge. - Modules & Imports — circular imports dissolved by late reads through the namespace.
- Caches, Registries & self — shared stores,
LazyShared, reading statics throughself.
The source
// ProjectStore.ts — a global store is just an ivue class published as a
// module singleton. No Pinia, no defineStore, no plugin: the class IS the
// store, and ProjectStore.Class.use() hands every caller the same instance.
import { reactive, ref, shallowRef } from 'vue';
import { Reactive, type ReactiveHelpers } from '../../ivue';
import { Static } from '../../Static';
class $ProjectStore {
/** The ONE store instance — a `$`-static, so it constructs on first
* read (after the app exists, immune to module-load order) and is
* cached on the receiver. It constructs through the namespace slot, so
* a test double swapped into `Class` is what gets built. */
protected static get $shared(): ProjectStore.Instance {
return new ProjectStore.Class();
}
/** The store as a `reactive()` view — refs auto-unwrap on read AND
* write, no `.value`. A store that wants to publish itself this way
* returns this from use() instead of $shared. */
protected static get $sharedReactive() {
return reactive(new ProjectStore.Class() as ProjectStore.Instance);
}
/** The store singleton: every caller receives the SAME instance. */
static use(): ProjectStore.Instance {
return this.$shared;
}
static readonly STORAGE_KEY = 'ivue-example-project-store';
// Outliving instance: the store outlives every component, so watchers
// registered here use $watch/$watchEffect (the instance's own scope).
constructor() {
this.hydrate();
this.$watchEffect(() => this.persist());
}
/** The one cast per class: instance code reads its own statics here. */
protected get self() {
return this.constructor as typeof $ProjectStore;
}
get projectName() {
return ref('Apollo');
}
get tasks() {
return shallowRef<ProjectStore.ProjectTask[]>([
{ id: 1, title: 'Design the flight plan', done: true },
{ id: 2, title: 'Fuel the first stage', done: false },
{ id: 3, title: 'Run the countdown checklist', done: false }
]);
}
get filter() {
return ref<ProjectStore.TaskFilter>('all');
}
// DERIVED — plain getters: zero bytes per instance, and there is only
// one instance anyway. Every consumer reads the same live values.
get taskCount() {
return this.tasks.value.length;
}
get completedCount() {
return this.tasks.value.filter((task) => task.done).length;
}
get progressPercent() {
const total = this.tasks.value.length;
return total === 0 ? 0 : Math.round((this.completedCount / total) * 100);
}
get progressBarStyle() {
return { width: `${this.progressPercent}%` };
}
get visibleTasks(): ProjectStore.ProjectTask[] {
if (this.filter.value === 'active') {
return this.tasks.value.filter((task) => !task.done);
}
if (this.filter.value === 'done') {
return this.tasks.value.filter((task) => task.done);
}
return this.tasks.value;
}
addTask(title: string) {
const trimmed = title.trim();
if (!trimmed) return;
this.tasks.value = [...this.tasks.value, { id: Date.now(), title: trimmed, done: false }];
}
toggleTask(id: number) {
this.tasks.value = this.tasks.value.map((task) =>
task.id === id ? { ...task, done: !task.done } : task
);
}
/** Cleanup for tests and hot swaps: stop the watchers, reset the cells. */
dispose() {
this.$stopEffects();
}
setFilter(filter: ProjectStore.TaskFilter) {
this.filter.value = filter;
}
/** The "done" filter as a switch — a second press returns to all. */
toggleDoneFilter() {
this.setFilter(this.filter.value === 'done' ? 'all' : 'done');
}
/** Restore a previous visit's state — reload the page and it holds. */
hydrate() {
try {
const saved = localStorage.getItem(this.self.STORAGE_KEY);
if (!saved) return;
const state = JSON.parse(saved);
if (typeof state.projectName === 'string') {
this.projectName.value = state.projectName;
}
if (Array.isArray(state.tasks)) this.tasks.value = state.tasks;
} catch {
/* corrupted storage — keep the seed state */
}
}
/** The $watchEffect in the constructor re-runs this whenever the name or
* the task list changes — its reads ARE the subscription. */
persist() {
const state = {
projectName: this.projectName.value,
tasks: this.tasks.value
};
try {
localStorage.setItem(this.self.STORAGE_KEY, JSON.stringify(state));
} catch {
/* storage unavailable — the store still works in memory */
}
}
}
interface $ProjectStore extends ReactiveHelpers {}
export namespace ProjectStore {
export const $Class = Static($ProjectStore); // anchor — it declares statics; children `extends` this
export let Class = Reactive($Class); // reactive — you `new` this (use() does, once)
export type Instance = typeof Class.Instance; // defineExpose type & reactive() interop
/* Types */
export interface ProjectTask {
id: number;
title: string;
done: boolean;
}
export type TaskFilter = 'all' | 'active' | 'done';
}// TaskBoard.ts — the board panel's view model: it reaches for the shared
// ProjectStore through a `$`-getter (cached per instance, resolved on
// first touch) and owns the one piece of state that is the panel's alone,
// the draft title of the task being added.
import { ref } from 'vue';
import { Reactive } from '../../ivue';
import { ProjectStore } from './ProjectStore';
class $TaskBoard {
// STORE — injected by `$`-getter; every consumer gets the one instance
protected get $project() {
return ProjectStore.Class.use();
}
/** The store, exposed for the template's dotted reads. */
get project() {
return this.$project;
}
// MUTABLE STATE — the panel's own
get newTaskTitle() {
return ref('');
}
// DERIVED — plain getters
get filterOptions() {
return ['all', 'active', 'done'] as const;
}
/** Whether a filter button is the active one (a per-item template condition). */
isFilter(option: ProjectStore.TaskFilter) {
return this.$project.filter.value === option;
}
submitTask() {
this.$project.addTask(this.newTaskTitle.value);
this.newTaskTitle.value = '';
}
}
export namespace TaskBoard {
export const $Class = $TaskBoard; // raw — children `extends` this
export let Class = Reactive($Class); // reactive — you `new` this
export type Instance = typeof Class.Instance; // defineExpose type & reactive() interop
}<script setup lang="ts">
import { TaskBoard } from './TaskBoard';
// wiring only — the board model reaches for the shared store itself
const board = new TaskBoard.Class();
const project = board.project;
// the state destructure
const {
// state refs
newTaskTitle
} = board;
</script>
<template>
<div class="board">
<div class="board__add">
<input v-model="newTaskTitle" placeholder="add a task…" @keyup.enter="board.submitTask()" />
<button class="btn primary" type="button" @click="board.submitTask()">add</button>
</div>
<div class="board__filters">
<button
v-for="option in board.filterOptions"
:key="option"
class="btn"
:class="{ primary: board.isFilter(option) }"
type="button"
@click="project.setFilter(option)"
>
{{ option }}
</button>
</div>
<ul class="board__list">
<li v-for="task in project.visibleTasks" :key="task.id">
<label :class="{ done: task.done }">
<input type="checkbox" :checked="task.done" @change="project.toggleTask(task.id)" />
{{ task.title }}
</label>
</li>
</ul>
</div>
</template>
<style scoped src="../example-pane.css"></style>
<style scoped>
.board__add {
display: flex;
gap: 8px;
margin-bottom: 10px;
}
.board__add input {
flex: 1;
min-width: 0;
padding: 7px 10px;
border-radius: 8px;
border: 1px solid rgba(148, 163, 184, 0.25);
background: rgba(255, 255, 255, 0.04);
color: inherit;
font-size: 13px;
}
.board__filters {
display: flex;
gap: 6px;
margin-bottom: 10px;
}
.board__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 6px;
font-size: 13.5px;
}
.board__list label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.board__list label.done {
opacity: 0.5;
text-decoration: line-through;
}
</style><script setup lang="ts">
import { ProjectStore } from './ProjectStore';
// the SAME singleton the TaskBoard writes — no props, no provide/inject
const project = ProjectStore.Class.use();
// the state destructure
const {
// state refs
projectName
} = project;
</script>
<template>
<div class="stats">
<div class="vals">
<div>
<div class="k">project</div>
<div class="n">
<input v-model="projectName" class="stats__name" />
</div>
</div>
<div>
<div class="k">done</div>
<div class="n grad">{{ project.completedCount }}/{{ project.taskCount }}</div>
</div>
<div>
<div class="k">progress</div>
<div class="n">{{ project.progressPercent }}%</div>
</div>
</div>
<div class="stats__bar">
<div class="stats__fill" :style="project.progressBarStyle" />
</div>
</div>
</template>
<style scoped src="../example-pane.css"></style>
<style scoped>
.stats__name {
width: 100%;
padding: 2px 0;
border: none;
border-bottom: 1px solid transparent;
background: transparent;
color: inherit;
font-size: 20px;
font-weight: 700;
}
.stats__name:hover,
.stats__name:focus {
border-bottom-color: rgba(148, 163, 184, 0.4);
outline: none;
}
.stats__bar {
height: 8px;
border-radius: 6px;
background: rgba(148, 163, 184, 0.15);
overflow: hidden;
}
.stats__fill {
height: 100%;
border-radius: 6px;
background: linear-gradient(120deg, #6366f1, #34d399);
transition: width 0.3s ease;
}
</style><script setup lang="ts">
import { ProjectStore } from './ProjectStore';
// the SAME singleton the other two panels use
const project = ProjectStore.Class.use();
// the state destructure
const {
// state refs
projectName,
filter
} = project;
</script>
<template>
<div class="reactive-view">
<p class="mono">projectName · filter — state bindings write the store's cells:</p>
<input v-model="projectName" class="reactive-view__input" />
<div class="row" style="margin-top: 10px">
<button class="btn" type="button" @click="project.toggleDoneFilter()">
filter: {{ filter }}
</button>
<span class="mono"> {{ project.completedCount }} done · {{ project.progressPercent }}% </span>
</div>
</div>
</template>
<style scoped src="../example-pane.css"></style>
<style scoped>
.reactive-view__input {
width: 100%;
padding: 7px 10px;
border-radius: 8px;
border: 1px solid rgba(148, 163, 184, 0.25);
background: rgba(255, 255, 255, 0.04);
color: inherit;
font-size: 13px;
}
</style><script setup lang="ts">
import TaskBoard from './TaskBoard.vue';
import ProjectStats from './ProjectStats.vue';
import ReactiveViewPanel from './ReactiveViewPanel.vue';
</script>
<template>
<div class="pane pane-wide">
<p class="note">
Three independent components, ZERO props between them — each calls ProjectStore.Class.use()
and receives the same singleton class instance.
</p>
<div class="store-grid">
<section>
<h3>TaskBoard.vue — writes the store</h3>
<TaskBoard />
</section>
<section>
<h3>ProjectStats.vue — reads the store</h3>
<ProjectStats />
</section>
<section>
<h3>ReactiveViewPanel.vue — writes name and filter</h3>
<ReactiveViewPanel />
</section>
</div>
</div>
</template>
<style scoped src="../example-pane.css"></style>
<style scoped>
.pane-wide {
max-width: 980px;
}
.store-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(300px, 100%), 1fr));
gap: 24px 26px;
align-items: start;
}
.store-grid h3 {
margin: 0 0 10px;
padding-bottom: 6px;
border-bottom: 1px solid rgba(148, 163, 184, 0.18);
font-size: 13.5px;
font-weight: 700;
letter-spacing: 0.02em;
color: #f1f5ff;
}
</style>