Skip to content

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:

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

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

ts
// 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';
}
ts
const project = ProjectStore.Class.use();

project.projectName = 'Artemis'; // ref write, no .value
project.filter = 'done';         // typechecks: Instance strips the readonly

Inside 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 plain watchEffect — 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 projectName and filter as state bindings — the same cells the first panel's addTask() and the store's own persist() read, so every panel re-renders from one write.

The source ​

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

Released under the MIT License.