Pinia Complete Guide | Vue 3 State Management with Composition API
이 글의 핵심
Pinia is the official state management library for Vue 3. It's simpler than Vuex, fully supports TypeScript, and works seamlessly with the Composition API.
Introduction
Pinia is the official state management library for Vue 3, succeeding Vuex. It provides a simpler API, excellent TypeScript support, and seamless integration with the Composition API.
Why Pinia?
Vuex (Old Way):
// Complex setup with mutations
mutations: {
INCREMENT(state) {
state.count++;
}
},
actions: {
increment({ commit }) {
commit('INCREMENT');
}
}
Pinia (Modern Way):
// Direct state mutation
const store = useCounterStore();
store.count++;
1. Installation & Setup
Install Pinia
npm install pinia
Configure in main.ts
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
const pinia = createPinia();
const app = createApp(App);
app.use(pinia);
app.mount('#app');
2. Defining Stores
Option Stores (Similar to Vuex)
// stores/counter.ts
import { defineStore } from 'pinia';
export const useCounterStore = defineStore('counter', {
state: () => ({
count: 0,
name: 'Counter',
}),
getters: {
doubleCount: (state) => state.count * 2,
// With parameters
countPlusN: (state) => {
return (n: number) => state.count + n;
},
},
actions: {
increment() {
this.count++;
},
async fetchCount() {
const response = await fetch('/api/count');
const data = await response.json();
this.count = data.count;
},
},
});
Setup Stores (Composition API Style)
// stores/user.ts
import { ref, computed } from 'vue';
import { defineStore } from 'pinia';
export const useUserStore = defineStore('user', () => {
// State
const user = ref<User | null>(null);
const isAuthenticated = ref(false);
// Getters
const displayName = computed(() => user.value?.name ?? 'Guest');
// Actions
async function login(email: string, password: string) {
const response = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
});
const data = await response.json();
user.value = data.user;
isAuthenticated.value = true;
}
function logout() {
user.value = null;
isAuthenticated.value = false;
}
return {
user,
isAuthenticated,
displayName,
login,
logout,
};
});
3. Using Stores in Components
In Composition API
<script setup lang="ts">
import { useCounterStore } from '@/stores/counter';
const counter = useCounterStore();
// Direct access
console.log(counter.count);
// Call actions
counter.increment();
// Reactive destructuring
import { storeToRefs } from 'pinia';
const { count, doubleCount } = storeToRefs(counter);
</script>
<template>
<div>
<p>Count: {{ count }}</p>
<p>Double: {{ doubleCount }}</p>
<button @click="counter.increment">Increment</button>
</div>
</template>
In Options API
<script lang="ts">
import { useCounterStore } from '@/stores/counter';
import { mapStores, mapState, mapActions } from 'pinia';
export default {
computed: {
...mapStores(useCounterStore),
...mapState(useCounterStore, ['count', 'doubleCount']),
},
methods: {
...mapActions(useCounterStore, ['increment']),
},
};
</script>
<template>
<div>
<p>{{ count }}</p>
<button @click="increment">Increment</button>
</div>
</template>
4. TypeScript Integration
// types/store.ts
export interface User {
id: number;
name: string;
email: string;
role: 'admin' | 'user';
}
// stores/auth.ts
import type { User } from '@/types/store';
export const useAuthStore = defineStore('auth', () => {
const user = ref<User | null>(null);
const token = ref<string | null>(null);
const isAdmin = computed(() => user.value?.role === 'admin');
async function login(credentials: { email: string; password: string }) {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentials),
});
if (!response.ok) {
throw new Error('Login failed');
}
const data = await response.json();
user.value = data.user;
token.value = data.token;
}
return {
user,
token,
isAdmin,
login,
};
});
5. Actions with Async/Await
export const useProductStore = defineStore('products', () => {
const products = ref<Product[]>([]);
const loading = ref(false);
const error = ref<string | null>(null);
async function fetchProducts() {
loading.value = true;
error.value = null;
try {
const response = await fetch('/api/products');
products.value = await response.json();
} catch (e) {
error.value = e instanceof Error ? e.message : 'Unknown error';
} finally {
loading.value = false;
}
}
async function createProduct(product: Omit<Product, 'id'>) {
const response = await fetch('/api/products', {
method: 'POST',
body: JSON.stringify(product),
});
const newProduct = await response.json();
products.value.push(newProduct);
return newProduct;
}
return {
products,
loading,
error,
fetchProducts,
createProduct,
};
});
6. Store Plugins
// plugins/persist.ts
import { PiniaPluginContext } from 'pinia';
export function piniaPersistedState({ store }: PiniaPluginContext) {
// Load from localStorage
const saved = localStorage.getItem(store.$id);
if (saved) {
store.$patch(JSON.parse(saved));
}
// Save on change
store.$subscribe((mutation, state) => {
localStorage.setItem(store.$id, JSON.stringify(state));
});
}
// main.ts
const pinia = createPinia();
pinia.use(piniaPersistedState);
7. Subscribing to Changes
const counter = useCounterStore();
// Subscribe to state changes
counter.$subscribe((mutation, state) => {
console.log('State changed:', state);
// Save 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 completed:', result);
});
onError((error) => {
console.error('Action failed:', error);
});
});
8. Best Practices
1. Modular Stores
// One store per domain
stores/
├── auth.ts # Authentication
├── cart.ts # Shopping cart
├── products.ts # Product catalog
└── ui.ts # UI state (modals, toasts)
2. Store Composition
export const useCartStore = defineStore('cart', () => {
const auth = useAuthStore(); // Use another store
const items = ref([]);
const canCheckout = computed(() => {
return auth.isAuthenticated && items.value.length > 0;
});
return { items, canCheckout };
});
3. Reset Store State
const counter = useCounterStore();
// Reset to initial state
counter.$reset();
4. Patch Multiple Changes
// ❌ Multiple reactivity triggers
counter.count = 1;
counter.name = 'New name';
// ✅ Single reactivity trigger
counter.$patch({
count: 1,
name: 'New name',
});
// ✅ Function patch (better for complex logic)
counter.$patch((state) => {
state.count++;
state.items.push({ id: 1 });
});
9. Testing
import { setActivePinia, createPinia } from 'pinia';
import { useCounterStore } from '@/stores/counter';
describe('Counter Store', () => {
beforeEach(() => {
setActivePinia(createPinia());
});
it('increments count', () => {
const counter = useCounterStore();
expect(counter.count).toBe(0);
counter.increment();
expect(counter.count).toBe(1);
});
it('doubles count', () => {
const counter = useCounterStore();
counter.count = 5;
expect(counter.doubleCount).toBe(10);
});
});
10. DevTools
Pinia integrates with Vue DevTools:
// Automatic in development
import { createPinia } from 'pinia';
const pinia = createPinia();
// DevTools will show:
// - All stores
// - State snapshots
// - Actions timeline
// - Mutations
Summary
Pinia modernizes Vue state management:
- Simple API - no mutations required
- TypeScript first - excellent type inference
- Composition API - natural integration
- Lightweight - only 1KB
- Devtools - powerful debugging
Key Takeaways:
- Replace Vuex with Pinia for simpler code
- Use setup stores with Composition API
- Leverage TypeScript for type safety
- Create modular stores per domain
- Use plugins for cross-cutting concerns
Next Steps:
- Learn Vue complete guide
- Compare with Zustand (React)
- Build full apps with Nuxt 3
Resources: