⚡ TECH BLOG
Home
Blog
Tags
About
⚡

Powered by Next.js 15 & Modern Web Tech ⚡

Back to Home

Vue 3 State Management with Pinia: The Modern Store

February 15, 2022
vuepiniastate-managementjavascript
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

  1. Use setup stores for better TypeScript support
  2. Keep stores focused - Single responsibility
  3. Use computed for derived state
  4. Handle errors in actions
  5. 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.

Share:

💬 Comments