Pinia Complete Guide | Vue 3 State Management with Composition API

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:

  1. Replace Vuex with Pinia for simpler code
  2. Use setup stores with Composition API
  3. Leverage TypeScript for type safety
  4. Create modular stores per domain
  5. Use plugins for cross-cutting concerns

Next Steps:

  • Learn Vue complete guide
  • Compare with Zustand (React)
  • Build full apps with Nuxt 3

Resources: