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 a singleton composable:
let store: InstanceType<typeof ProjectStore.Class> | undefined;
export function useProjectStore() {
return (store ??= new ProjectStore.Class());
}No Pinia, no defineStore, no plugin registration. Every component that calls useProjectStore() receives the same instance; state written in one panel renders in every other.
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.
The optional reactive() view
Some teams prefer store reads without .value. The same singleton wraps in reactive() — refs auto-unwrap on read and write — and the cast through ProjectStore.Instance 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):
export function useProjectStoreReactive() {
return reactive(useProjectStore() as ProjectStore.Instance);
}const project = useProjectStoreReactive();
project.projectName = 'Artemis'; // ref write, no .value
project.filter = 'done'; // typechecks because of InstanceBoth views read and write the SAME cells — pick per consumer, not per app.
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
project.projectNamewith no.value— thereactive()view at work, fully typed.
The source
// ProjectStore.ts — a global store is just an ivue class with a singleton
// composable. No Pinia, no defineStore, no plugin: the class IS the store,
// and useProjectStore() hands every caller the same instance.
import { reactive, ref, shallowRef } from 'vue';
import { Reactive } from '../../ivue';
export interface ProjectTask {
id: number;
title: string;
done: boolean;
}
export type TaskFilter = 'all' | 'active' | 'done';
const STORAGE_KEY = 'ivue-example-project-store';
class $ProjectStore {
// 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());
}
get projectName() {
return ref('Apollo');
}
get tasks() {
return shallowRef<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<TaskFilter>('all');
}
// DERIVED — plain getters: zero bytes per instance, and there is only
// one instance anyway. Every consumer reads the same live values.
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 visibleTasks(): 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,
);
}
setFilter(filter: TaskFilter) {
this.filter.value = filter;
}
/** Restore a previous visit's state — reload the page and it holds. */
hydrate() {
try {
const saved = localStorage.getItem(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(STORAGE_KEY, JSON.stringify(state));
} catch {
/* storage unavailable — the store still works in memory */
}
}
}
export namespace ProjectStore {
export const $Class = $ProjectStore; // raw — children `extends` this
export let Class = Reactive($Class); // reactive — you `new` this
export type Instance = typeof Class.Instance; // defineExpose type & reactive() interop
}
let store: InstanceType<typeof ProjectStore.Class> | undefined;
/** The store composable: every caller receives the SAME instance. */
export function useProjectStore() {
return (store ??= new ProjectStore.Class());
}
/**
* Optional `reactive()` view of the same singleton — refs auto-unwrap on
* read AND write, so consumers drop every `.value`. The cast through
* `ProjectStore.Instance` is load-bearing: it strips the readonly that TS
* puts on get-only accessors, so writes typecheck as they behave.
*/
export function useProjectStoreReactive() {
return reactive(useProjectStore() as ProjectStore.Instance);
}<script setup lang="ts">
import { ref } from 'vue';
import { useProjectStore } from './ProjectStore';
const project = useProjectStore();
// the state destructure — same rules as any ivue instance
const {
// state refs
filter,
} = project;
const newTaskTitle = ref('');
function submitTask() {
project.addTask(newTaskTitle.value);
newTaskTitle.value = '';
}
</script>
<template>
<div class="board">
<div class="board__add">
<input
v-model="newTaskTitle"
placeholder="add a task…"
@keyup.enter="submitTask"
/>
<button class="btn primary" type="button" @click="submitTask">add</button>
</div>
<div class="board__filters">
<button
v-for="option in ['all', 'active', 'done'] as const"
:key="option"
class="btn"
:class="{ primary: filter === 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 { useProjectStore } from './ProjectStore';
// the SAME singleton the TaskBoard writes — no props, no provide/inject
const project = useProjectStore();
// 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.tasks.value.length }}
</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="{ width: project.progressPercent + '%' }"
/>
</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">
// The OPTIONAL consumption style: the same singleton wrapped in reactive().
// Refs auto-unwrap on read AND write — no .value anywhere in this file.
// The Instance cast inside useProjectStoreReactive() is what makes the
// writes typecheck (it strips TS's readonly on get-only accessors).
import { useProjectStoreReactive } from './ProjectStore';
const project = useProjectStoreReactive();
</script>
<template>
<div class="reactive-view">
<p class="mono">
project.projectName · project.filter — reads and writes, no .value:
</p>
<input v-model="project.projectName" class="reactive-view__input" />
<div class="row" style="margin-top: 10px">
<button
class="btn"
type="button"
@click="project.filter = project.filter === 'done' ? 'all' : 'done'"
>
filter: {{ project.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
useProjectStore() and receives the same singleton class instance. The
third panel consumes the store through the optional reactive() wrapper
(typed via ProjectStore.Instance), so its reads and writes drop every
.value.
</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 — reactive() view, no .value</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>