Updated June 2026. Tested on Vue 3 with Pinia 2 and <script setup>. Part of the Techalyst Vue 3 series.

When state needs to be shared across many components, provide/inject starts to creak and you want a real store. Pinia is Vue's official answer: tiny, typed, and built around the Composition API. No mutations, no dispatch, just stores that feel like plain reactive objects. Here is the working knowledge you need.

Setup

Install Pinia and register it on the app.

// main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'

const pinia = createPinia()
createApp(App).use(pinia).mount('#app')

Keep the createPinia() result in a variable rather than chaining, you will need it later if you register plugins.

Defining a store

A store has three parts: state, getters, and actions. The first argument to defineStore is a unique id.

// stores/counter.js
import { defineStore } from 'pinia'

export const useCounterStore = defineStore('counter', {
  state: () => ({
    count: 0,
    title: 'Counter',
  }),
  getters: {
    capTitle: (state) => state.title.toUpperCase(),
  },
  actions: {
    increment() {
      this.count++ // mutate state directly, no commit
    },
    async load() {
      const { data } = await axios.get('/api/count')
      this.count = data.value
    },
  },
})

A few things to notice if you are coming from Vuex. state must be a function (a factory) so each app gets its own copy. Getters are like computed values and take state as an argument. And actions just change state directly through this, there is no separate mutation step and actions can be async. This is the options store style. There is also a setup store style that mirrors a composable:

export const useCounterStore = defineStore('counter', () => {
  const count = ref(0)
  const capTitle = computed(() => 'Counter'.toUpperCase())
  function increment() { count.value++ }
  return { count, capTitle, increment }
})

Both produce the same store. Use whichever reads better to you.

Using a store in a component

defineStore returns a function you call inside a component. It always hands back the same instance, so every component shares one store.

<script setup>
import { useCounterStore } from '@/stores/counter'

const counter = useCounterStore()
</script>

<template>
  <h1>{{ counter.capTitle }}</h1>
  <button @click="counter.increment()">{{ counter.count }}</button>
  <input v-model="counter.title" />
</template>

Accessing state, getters, and actions through the counter instance is the recommended style, the store name reads as a namespace and makes the data's origin obvious.

Destructuring with storeToRefs

You might want to pull a few values out for brevity. Do not destructure the store directly, that strips the reactivity. Use storeToRefs, which wraps each state value and getter in a ref so they stay reactive. Actions are plain functions, so pull those straight off the store.

<script setup>
import { storeToRefs } from 'pinia'
import { useCounterStore } from '@/stores/counter'

const counter = useCounterStore()
const { count, capTitle } = storeToRefs(counter) // reactive
const { increment } = counter                    // actions: direct
</script>

This is the single most common Pinia mistake, so it is worth committing to memory: state and getters through storeToRefs, actions straight off the store.

Mutating state

Beyond assigning directly (counter.count = 5), Pinia gives you $patch for changing several values at once, which is more efficient than separate assignments.

counter.$patch({ count: 10, title: 'Updated' })

// function form, best for arrays and complex logic
counter.$patch((state) => {
  state.items.push(newItem)
  state.total = state.items.length
})

counter.$reset() // back to the state() defaults

Reacting to changes

Two subscription hooks let you observe a store from outside. $subscribe fires on any state change, and $onAction wraps action calls with before, after, and error hooks, which is perfect for cross-cutting concerns like a loading flag.

counter.$subscribe((mutation, state) => {
  localStorage.setItem('count', state.count) // persist on every change
})

counter.$onAction(({ name, after, onError, store }) => {
  if (name === 'load') {
    store.title = 'Loading...'
    after(() => { store.title = 'Counter' })
    onError((err) => { store.title = 'Failed' })
  }
})

Pushing the loading and error handling into $onAction keeps the action itself lean: it just fetches and returns, while the UI state lives in the hook.

Why Pinia over Vuex

If you have used Vuex, Pinia will feel like it removed all the ceremony. Mutations are gone, dispatch and commit are gone, stores are independent modules by default, and the whole thing is built for the Composition API and TypeScript. Vuex still works and is covered in its own post, but it is in maintenance mode. For any new Vue 3 app, Pinia is the recommended choice.

Wrapping up

Define a store with state, getters, and actions, call useStore() inside a component, and read through the instance. Reach for storeToRefs when you destructure so reactivity survives, use $patch for batch updates, and lean on $subscribe and $onAction for persistence and loading logic. It is a small API that scales from a single shared counter to an entire app's state.

More in the series: provide and inject and state management with Vuex 4. Questions welcome below.