Vue Tutorial · Lessons

Foundation

Basic

Medium

Advanced

State Management with Pinia

Stores, getters, actions and when you need global state.

Props and emits work well for parent ↔ child communication. Once the same data is needed by components in different branches of the tree, passing it through every level becomes painful. A header badge showing the open-task count, a sidebar filter and the board itself might all need the task list. That's when you want shared state, and in Vue that means Pinia, the official state-management library and successor to Vuex.

The problem: prop drilling

Prop drilling App (tasks) Layout Header Sidebar FilterBox :tasks ↓ @update ↑ Pinia store useTaskStore() state · getters · actions Header FilterBox TaskBoard

Intermediate components like Layout and Sidebar end up accepting props they don't use, only to pass them on. Every refactor of the tree means rewiring props and events. A store removes the middlemen: any component can read from it and call its actions directly.

When you don't need Pinia. If state is used by one component, or one parent and its direct children, keep it local or use props. If one subtree needs it, provide/inject may be enough. Reach for a store when unrelated parts of the app share state, when state must survive route changes, or when you want devtools time-travel and a single place for business logic.

Defining a store

Pinia supports two syntaxes. The setup store is a function that looks exactly like <script setup>: refs become state, computeds become getters and functions become actions.

// src/stores/tasks.js
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import axios from 'axios';

export const useTaskStore = defineStore('tasks', () => {
    // state
    const tasks = ref([]);
    const filter = ref('all');
    const loading = ref(false);

    // getters
    const openCount = computed(() => tasks.value.filter(t => !t.done).length);
    const visibleTasks = computed(() => {
        if (filter.value === 'open') return tasks.value.filter(t => !t.done);
        if (filter.value === 'done') return tasks.value.filter(t => t.done);
        return tasks.value;
    });

    // actions
    async function fetchTasks() {
        loading.value = true;
        try {
            const { data } = await axios.get('/api/tasks');
            tasks.value = data.data;
        } finally {
            loading.value = false;
        }
    }

    async function toggle(task) {
        task.done = !task.done;                       // optimistic update
        try {
            await axios.patch(`/api/tasks/${task.id}`, { done: task.done });
        } catch (e) {
            task.done = !task.done;                   // roll back on failure
            throw e;
        }
    }

    function add(title, priority = 'medium') {
        tasks.value.push({ id: Date.now(), title, priority, done: false });
    }

    return { tasks, filter, loading, openCount, visibleTasks, fetchTasks, toggle, add };
});

The equivalent options store, which will feel familiar from Vuex:

export const useTaskStore = defineStore('tasks', {
    state: () => ({ tasks: [], filter: 'all', loading: false }),
    getters: {
        openCount: (state) => state.tasks.filter(t => !t.done).length,
    },
    actions: {
        add(title, priority = 'medium') {
            this.tasks.push({ id: Date.now(), title, priority, done: false });
        },
    },
});

Register Pinia once when you create the app:

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

createApp(App).use(createPinia()).mount('#app');

Using a store in components

<!-- AppHeader.vue -->
<script setup>
import { storeToRefs } from 'pinia';
import { useTaskStore } from '@/stores/tasks';

const store = useTaskStore();
const { openCount } = storeToRefs(store); // keeps reactivity when destructuring
</script>

<template>
    <header>Task board <span class="badge">{{ openCount }} open</span></header>
</template>
<!-- TaskBoard.vue -->
<script setup>
import { onMounted } from 'vue';
import { storeToRefs } from 'pinia';
import { useTaskStore } from '@/stores/tasks';

const store = useTaskStore();
const { visibleTasks, loading } = storeToRefs(store); // state & getters
const { toggle, fetchTasks } = store;                 // actions destructure directly

onMounted(fetchTasks);
</script>

Destructuring gotcha: const { openCount } = useTaskStore() gives you a plain, non-reactive snapshot. The store is a reactive() object, so destructuring breaks the link. Use storeToRefs(store) for state and getters. Actions are plain functions and can be destructured directly.

One store, sibling components

Pinia isn't loaded on this page, so the demo uses the same idea in its most minimal form: a single reactive() object created once, outside any component, plus getters and actions. The Header, Filter and Board components below are siblings. None of them passes props to the others, yet they stay in sync because they share one store.

The store behind it is about 20 lines:

const taskStore = reactive({
    tasks: [ /* … */ ],
    filter: 'all',
    get openCount() { return this.tasks.filter(t => !t.done).length; },
    get visibleTasks() { /* … */ },
    add(title, priority) { this.tasks.push({ id: Date.now(), title, priority, done: false }); },
    toggle(task) { task.done = !task.done; },
});

// Any component:
const Header = { setup: () => ({ store: taskStore }), template: `{{ store.openCount }} open` };

So why use Pinia at all?

A hand-rolled reactive() store works, and for a tiny app it's fine. Pinia adds:

Feature Hand-rolled Pinia
Vue Devtools: inspect, edit and time-travel state ❌ ✅
Safe with SSR: a fresh store per request, no cross-request leaks ❌ (module singleton) ✅
Plugins (persistence, undo, logging) DIY ✅ pinia.use()
$reset, $patch, $subscribe, $onAction DIY ✅
Hot module replacement during dev ❌ ✅
Full TypeScript inference Partial ✅

Store APIs worth knowing

const store = useTaskStore();

// Batch several changes into one devtools entry / one subscriber call
store.$patch({ filter: 'open', loading: false });
store.$patch((state) => { state.tasks.push(newTask); });

// React to any state change, e.g. persist to localStorage
store.$subscribe((mutation, state) => {
    localStorage.setItem('tasks', JSON.stringify(state.tasks));
});

// Hook into actions: logging, analytics, error reporting
store.$onAction(({ name, args, after, onError }) => {
    const start = Date.now();
    after(() => console.log(`${name} took ${Date.now() - start}ms`));
    onError((e) => reportToSentry(e));
});

Stores can also use other stores. Call useAuthStore() inside an action or getter of useTaskStore, and Pinia resolves the dependency when it's needed.

Undo with $subscribe-style history

This demo keeps a stack of state snapshots every time an action runs, which is roughly how devtools time-travel works. Make some changes, then step back through history.

Testing stores

Stores are plain functions, so they test cleanly with Vitest. Create a fresh Pinia per test:

import { setActivePinia, createPinia } from 'pinia';
import { beforeEach, describe, it, expect } from 'vitest';
import { useTaskStore } from '@/stores/tasks';

describe('task store', () => {
    beforeEach(() => setActivePinia(createPinia()));

    it('counts open tasks', () => {
        const store = useTaskStore();
        store.add('Write tests');
        store.add('Ship it');
        store.tasks[0].done = true;
        expect(store.openCount).toBe(1);
    });
});

For component tests, @pinia/testing provides createTestingPinia(), which stubs every action with vi.fn() so you can assert a component called toggle without hitting the API.

Organising stores in a real app

  • One store per domain: useAuthStore, useTaskStore, useProjectStore, useUiStore. Not one giant store.
  • Server state vs client state. Data that mirrors the API (tasks, projects) can live in Pinia, or in a server-cache library like TanStack Query that handles caching and refetching. Pure UI state (sidebar open, current filter) is perfect for Pinia.
  • Keep business rules in actions. Components call store.completeTask(task), not task.done = true; await axios.... Logic stays testable and in one place.
  • Don't store derived data. If it can be computed, make it a getter.

Key takeaways

  • Use a store when unrelated components share state, and keep local state local.
  • A Pinia setup store maps ref → state, computed → getters and functions → actions.
  • Destructure state and getters with storeToRefs(). Destructure actions directly.
  • The core idea is simply "one reactive object shared by many components". Pinia adds devtools, SSR safety, plugins, $patch, $subscribe and typing.
  • Test stores with a fresh createPinia() per test, and use createTestingPinia() in component tests.