Vue 3 State Management with Pinia: The Modern Store
Vue 3 State Management with Pinia: The Modern Store
Pinia is Vue's official state management library, designed to work seamlessly with Vue 3's Composition API.
Why Pinia?
- No mutations - Only state, getters, and actions
- TypeScript support - Full type inference
- DevTools support - Full Vue DevTools integration
- Smaller bundle size - ~1KB
- Modular design - Each store is independent
Installation
npm install pinia
Basic Setup
// main.js
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
const app = createApp(App);
app.use(createPinia());
app.mount('#app');
Defining a Store
Option Store
// stores/counter.js
import { defineStore } from 'pinia';
export const useCounterStore = defineStore('counter', {
state: () => ({
count: 0,
name: 'Counter',
}),
getters: {
doubleCount: (state) => state.count * 2,
// With this access
doubleCountPlusOne() {
return this.doubleCount + 1;
},
},
actions: {
increment() {
this.count++;
},
async fetchCount() {
const response = await fetch('/api/count');
this.count = await response.json();
},
},
});
Setup Store
export const useCounterStore = defineStore('counter', () => {
// State
const count = ref(0);
const name = ref('Counter');
// Getters
const doubleCount = computed(() => count.value * 2);
// Actions
function increment() {
count.value++;
}
async function fetchCount() {
const response = await fetch('/api/count');
count.value = await response.json();
}
return {
count,
name,
doubleCount,
increment,
fetchCount,
};
});
Using the Store
<script setup>
import { useCounterStore } from '@/stores/counter';
import { storeToRefs } from 'pinia';
const counter = useCounterStore();
// Reactive state access
const { count, doubleCount } = storeToRefs(counter);
// Actions can be destructured directly
const { increment } = counter;
</script>
<template>
<div>
<p>Count: {{ count }}</p>
<p>Double: {{ doubleCount }}</p>
<button @click="increment">Increment</button>
</div>
</template>
Modifying State
const counter = useCounterStore();
// Direct modification
counter.count++;
// $patch with object
counter.$patch({
count: counter.count + 1,
name: 'New Name',
});
// $patch with function
counter.$patch((state) => {
state.count++;
state.name = 'Updated';
});
// Replace entire state
counter.$state = { count: 0, name: 'Reset' };
Resetting State
const counter = useCounterStore();
// Reset to initial state
counter.$reset();
Subscribing to State Changes
// Subscribe to all changes
counter.$subscribe((mutation, state) => {
console.log(mutation.type); // 'direct' | 'patch object' | 'patch function'
console.log(mutation.storeId); // 'counter'
console.log(mutation.payload); // patch object
// Persist to localStorage
localStorage.setItem('counter', JSON.stringify(state));
});
// Subscribe to actions
counter.$onAction(({ name, args, after, onError }) => {
console.log(`Action ${name} called with`, args);
after((result) => {
console.log(`Action ${name} finished with`, result);
});
onError((error) => {
console.error(`Action ${name} failed:`, error);
});
});
Composing Stores
// stores/user.js
export const useUserStore = defineStore('user', () => {
const user = ref(null);
async function login(credentials) {
user.value = await api.login(credentials);
}
return { user, login };
});
// stores/cart.js
export const useCartStore = defineStore('cart', () => {
const userStore = useUserStore();
const items = ref([]);
const total = computed(() =>
items.value.reduce((sum, item) => sum + item.price, 0)
);
function addItem(item) {
if (!userStore.user) {
throw new Error('Must be logged in');
}
items.value.push(item);
}
return { items, total, addItem };
});
Plugins
// plugins/persistedState.js
import { createPinia } from 'pinia';
const pinia = createPinia();
pinia.use(({ store }) => {
// Load from localStorage
const savedState = localStorage.getItem(store.$id);
if (savedState) {
store.$patch(JSON.parse(savedState));
}
// Watch for changes
store.$subscribe((mutation, state) => {
localStorage.setItem(store.$id, JSON.stringify(state));
});
});
export default pinia;
TypeScript Support
interface User {
id: number;
name: string;
email: string;
}
interface UserState {
user: User | null;
loading: boolean;
}
export const useUserStore = defineStore('user', {
state: (): UserState => ({
user: null,
loading: false,
}),
getters: {
isLoggedIn: (state): boolean => state.user !== null,
userName(state): string {
return state.user?.name ?? 'Guest';
},
},
actions: {
async login(email: string, password: string): Promise<void> {
this.loading = true;
try {
this.user = await api.login(email, password);
} finally {
this.loading = false;
}
},
},
});
Real-World Example: Todo Store
// stores/todos.js
import { defineStore } from 'pinia';
export const useTodosStore = defineStore('todos', () => {
const todos = ref([]);
const filter = ref('all');
const loading = ref(false);
const filteredTodos = computed(() => {
switch (filter.value) {
case 'active':
return todos.value.filter(t => !t.completed);
case 'completed':
return todos.value.filter(t => t.completed);
default:
return todos.value;
}
});
const remaining = computed(() =>
todos.value.filter(t => !t.completed).length
);
async function fetchTodos() {
loading.value = true;
try {
const response = await fetch('/api/todos');
todos.value = await response.json();
} finally {
loading.value = false;
}
}
async function addTodo(title) {
const todo = await fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, completed: false }),
}).then(r => r.json());
todos.value.push(todo);
}
async function toggleTodo(id) {
const todo = todos.value.find(t => t.id === id);
if (todo) {
await fetch(`/api/todos/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ completed: !todo.completed }),
});
todo.completed = !todo.completed;
}
}
return {
todos,
filter,
loading,
filteredTodos,
remaining,
fetchTodos,
addTodo,
toggleTodo,
};
});
Best Practices
- Use setup stores for better TypeScript support
- Keep stores focused - Single responsibility
- Use computed for derived state
- Handle errors in actions
- Use plugins for cross-cutting concerns
Conclusion
Pinia provides a clean, intuitive, and type-safe approach to state management in Vue 3. Its modular design and Composition API integration make it the recommended choice for Vue applications.