@iappx/pinia-di

Class-based Pinia stores with constructor dependency injection powered by tsyringe.
Install
npm install @iappx/pinia-di
Latest Version:1.0.0
Vue:3
License:MIT
Last Updated:Sep 23, 2026
Links: GitHub  ·  npm
Maintainer: iappx

Pinia DI

npm version license

Class-based Pinia stores with constructor dependency injection powered by tsyringe.

Write a store as a class, declare its services as constructor parameters, and resolve it from the container like any other service. Fields become state, getters become computed values, methods become actions.

import { InjectableStore, StoreBase } from '@iappx/pinia-di'
import { inject } from 'tsyringe'
import { UserApi, type User } from './UserApi'

@InjectableStore
export class UserStore extends StoreBase {
    public user: User | null = null
    public loading = false

    constructor(@inject(UserApi) private readonly api: UserApi) {
        super()
    }

    public get isSignedIn(): boolean {
        return this.user !== null
    }

    public async load(): Promise<void> {
        this.loading = true
        try {
            this.user = await this.api.currentUser()
        } finally {
            this.loading = false
        }
    }
}
const users = container.resolve(UserStore)
await users.load()

Installation

npm install @iappx/pinia-di pinia tsyringe reflect-metadata

vue (3.3+), pinia (2.1+ or 3), tsyringe and reflect-metadata are peer dependencies. Import the polyfill once, before anything that touches tsyringe, and install Pinia as usual:

import 'reflect-metadata'

app.use(createPinia())

TypeScript configuration

{
  "compilerOptions": {
    "experimentalDecorators": true
  }
}

emitDecoratorMetadata is optional. esbuild (and therefore Vite) does not emit it, so annotate every constructor parameter with @inject(Token); that works with or without metadata.

Keep class names in production builds

A store's Pinia id defaults to its class name. A minifier that renames classes makes every store collide, and @InjectableStore throws on the second one. Either keep names in the build (keepNames in esbuild / Rolldown) or give each store an explicit id:

@InjectableStore({ id: 'user' })
export class UserStore extends StoreBase {}

How a class becomes a store

  • Fields are state. Every field the instance holds is part of the store's $state and is reactive, including a field first assigned in an action long after construction.
  • Getters are computed values. A getter with a setter is a writable computed; assigning to a getter without one throws.
  • Methods are actions. Overriding works as in any class: the most derived method wins, and super.load() reaches the parent.
  • Dependencies are not state. Whatever the container passed to the constructor stays a plain, non-reactive field that only the store's own code can read: a service is the very instance the container holds, and a Ref inside it stays a Ref. A value derived from a dependency (this.query = api.query()) is an ordinary field and therefore state.
  • this is the store. Field initializers, the constructor, getters and actions all see the same live store, so a closure created in the constructor keeps working.
  • setup() runs once, right after the store is built. Subscribe to events there, not in a getter.

When a store is built

Decorating a class only registers it. The store — and its constructor — runs when it is first resolved from the container, once per Pinia instance. A store can therefore depend on services that are registered later, at application start-up.

A failed construction leaves nothing behind: the next resolution builds the store again.

Stores depending on stores

Inject a store like any other dependency:

@InjectableStore
export class CartStore extends StoreBase {
    constructor(@inject(UserStore) private readonly users: UserStore) {
        super()
    }
}

Two stores may depend on each other through tokens resolved lazily (delay() or a factory). While one of them is being built the other sees it unfinished, so do not call into the partner from a constructor.

API

Export Description
@InjectableStore / @InjectableStore({ id }) Registers the class as a singleton Pinia store in tsyringe's global container.
StoreBase The base class every store extends. Declares the setup() hook.
PiniaDiError Thrown on invalid declarations: a store not extending StoreBase, a duplicate id.

Choosing the container

Stores are registered in tsyringe's global container, and their dependencies are resolved from the container the store is resolved from — a child container works, too. Pinia keeps one instance per store id, so the container that resolves a store first is the one its dependencies come from.

License

MIT

Related Packages

@iappx/vue-facing-di

Class-based Vue 3 components with constructor dependency injection powered by ts...

605 0
@nonnajs/vue

Vue bindings for @nonnajs/di - a component plus useInjection()/useOptionalInject...

439 2
iocraft

A lightweight IOC container for Vue 3 using the Composition API.

2,515 2
@mpxjs/pinia

A pinia style store in miniprogram

24,265 3,947
pinia-class-component

ECMAScript / TypeScript decorator for class-style Pinia store.

47,479 39

Version History

Version Released Vue License
1.0.0 ^3.3.0 MIT