State Management

Learn how to manage state in Vue.js applications using Pinia, the official state management solution. State management helps you share data between components and keep your application organized.

What is State Management?

State management is the practice of storing and managing application data in a centralized location. Instead of passing data between components through props and events, you store it in a “store” that any component can access.

Why Use Pinia?

Pinia is the recommended state management solution for Vue.js because:

  • Simple and intuitive - Easy to learn and use
  • TypeScript friendly - Excellent TypeScript support
  • Devtools integration - Great debugging experience
  • Modular by design - Organize stores by feature
  • No mutations - Simpler than Vuex

Setting Up Pinia

Installation

First, install Pinia in your project:

npm install pinia

Configuration

Set up Pinia in your main application file:

// 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')

Creating Your First Store

A store is where you define your state, getters, and actions for a specific feature.

// stores/counter.js
import { defineStore } from 'pinia'

export const useCounterStore = defineStore('counter', {
  state: () => ({
    count: 0,
    name: 'Counter App'
  }),
  
  getters: {
    doubleCount: (state) => state.count * 2,
    isEven: (state) => state.count % 2 === 0
  },
  
  actions: {
    increment() {
      this.count++
    },
    decrement() {
      this.count--
    },
    reset() {
      this.count = 0
    },
    incrementBy(amount) {
      this.count += amount
    }
  }
})

Using Stores in Components

Basic Usage

<template>
  <div class="counter-display">
    <h2>{{ counterStore.name }}</h2>
    <p>Count: {{ counterStore.count }}</p>
    <p>Double Count: {{ counterStore.doubleCount }}</p>
    <p>Is Even: {{ counterStore.isEven ? 'Yes' : 'No' }}</p>
    
    <button @click="counterStore.increment()">+</button>
    <button @click="counterStore.decrement()">-</button>
    <button @click="counterStore.reset()">Reset</button>
    <button @click="counterStore.incrementBy(5)">+5</button>
  </div>
</template>

<script setup>
import { useCounterStore } from '@/stores/counter'

const counterStore = useCounterStore()
</script>

<style scoped>
.counter-display {
  text-align: center;
  padding: 20px;
  border: 1px solid #ddd;
  border-radius: 8px;
}

button {
  margin: 0 5px;
  padding: 8px 16px;
  background-color: #42b883;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

button:hover {
  background-color: #369870;
}
</style>

Options API Usage

<template>
  <div>
    <p>Count: {{ count }}</p>
    <button @click="increment">+</button>
  </div>
</template>

<script>
import { useCounterStore } from '@/stores/counter'
import { mapState, mapActions } from 'pinia'

export default {
  computed: {
    ...mapState(useCounterStore, ['count', 'doubleCount'])
  },
  methods: {
    ...mapActions(useCounterStore, ['increment', 'decrement'])
  }
}
</script>

Real-World Example: User Store

Here’s a more practical example for managing user authentication:

// stores/user.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useUserStore = defineStore('user', () => {
  // State
  const user = ref(null)
  const isLoading = ref(false)
  const error = ref(null)
  
  // Getters
  const isAuthenticated = computed(() => !!user.value)
  const userName = computed(() => user.value?.name || 'Guest')
  const userRole = computed(() => user.value?.role || 'user')
  
  // Actions
  const login = async (credentials) => {
    isLoading.value = true
    error.value = null
    
    try {
      // Simulate API call
      const response = await mockApiLogin(credentials)
      user.value = response.user
      return response
    } catch (err) {
      error.value = err.message
      throw err
    } finally {
      isLoading.value = false
    }
  }
  
  const logout = () => {
    user.value = null
    error.value = null
  }
  
  const updateProfile = async (profileData) => {
    isLoading.value = true
    
    try {
      // Simulate API call
      const response = await mockApiUpdateProfile(profileData)
      user.value = { ...user.value, ...response.user }
      return response
    } catch (err) {
      error.value = err.message
      throw err
    } finally {
      isLoading.value = false
    }
  }
  
  return {
    // State
    user,
    isLoading,
    error,
    
    // Getters
    isAuthenticated,
    userName,
    userRole,
    
    // Actions
    login,
    logout,
    updateProfile
  }
})

// Mock API functions (replace with real API calls)
async function mockApiLogin(credentials) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (credentials.email === 'user@example.com' && credentials.password === 'password') {
        resolve({
          user: {
            id: 1,
            name: 'John Doe',
            email: 'user@example.com',
            role: 'user'
          },
          token: 'mock-jwt-token'
        })
      } else {
        reject(new Error('Invalid credentials'))
      }
    }, 1000)
  })
}

async function mockApiUpdateProfile(profileData) {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve({ user: profileData })
    }, 500)
  })
}

Using the User Store

<!-- components/LoginForm.vue -->
<template>
  <div class="login-form">
    <h2>Login</h2>
    
    <form @submit.prevent="handleLogin">
      <div class="form-group">
        <label for="email">Email:</label>
        <input 
          id="email"
          v-model="email" 
          type="email" 
          required 
        />
      </div>
      
      <div class="form-group">
        <label for="password">Password:</label>
        <input 
          id="password"
          v-model="password" 
          type="password" 
          required 
        />
      </div>
      
      <button type="submit" :disabled="userStore.isLoading">
        {{ userStore.isLoading ? 'Logging in...' : 'Login' }}
      </button>
      
      <div v-if="userStore.error" class="error">
        {{ userStore.error }}
      </div>
    </form>
  </div>
</template>

<script setup>
import { ref } from 'vue'
import { useUserStore } from '@/stores/user'

const userStore = useUserStore()
const email = ref('')
const password = ref('')

const handleLogin = async () => {
  try {
    await userStore.login({
      email: email.value,
      password: password.value
    })
    // Redirect or show success message
  } catch (error) {
    // Error is already handled in the store
  }
}
</script>

<style scoped>
.login-form {
  max-width: 300px;
  margin: 0 auto;
  padding: 20px;
  border: 1px solid #ddd;
  border-radius: 8px;
}

.form-group {
  margin-bottom: 15px;
}

label {
  display: block;
  margin-bottom: 5px;
}

input {
  width: 100%;
  padding: 8px;
  border: 1px solid #ddd;
  border-radius: 4px;
}

button {
  width: 100%;
  padding: 10px;
  background-color: #42b883;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

button:disabled {
  background-color: #ccc;
}

.error {
  margin-top: 10px;
  color: red;
  text-align: center;
}
</style>

Store Organization

For larger applications, organize your stores by feature:

stores/
├── auth.js          # Authentication state
├── user.js          # User profile state
├── products.js      # Product catalog state
├── cart.js          # Shopping cart state
├── notifications.js # Notification state
└── index.js         # Store exports (optional)

Best Practices

  1. Keep stores focused - Each store should manage one domain
  2. Use composition stores - They’re more flexible and easier to test
  3. Don’t overuse stores - Only use stores for truly shared state
  4. Handle side effects in actions - Keep API calls and async operations in actions
  5. Use TypeScript - Get better type safety and autocompletion

Next Steps

Learn how to integrate with APIs in the API integration tutorial.

External Resources

Last updated on