Skip to content

State Management with Pinia in Vue 3 Applications

In this guide, we'll explore how to use Pinia, the intuitive state management library for Vue.js, in a Vue 3 application. We'll build a movie app example, covering each feature step by step using the Composition API with the latest syntax. We'll also highlight important tips, pitfalls, and best practices using custom containers.

What is Pinia?

Pinia is the official state management library for Vue.js, designed to be a replacement for Vuex. It offers a simpler API and full TypeScript support, making state management in Vue applications more intuitive and maintainable.

Why Use Pinia?

  • Intuitive API: Easier to learn and use compared to Vuex.
  • TypeScript Support: Built with TypeScript, providing better type inference.
  • DevTools Integration: Works seamlessly with Vue DevTools.
  • Modularity: Encourages modular store definitions.

Setting Up Pinia

Step 1: Install Pinia

Navigate to your project directory and install Pinia.

bash
cd my-vue-app
bash
npm install pinia
bash
yarn add pinia

Step 2: Create a Pinia Store Directory

Create a stores directory inside your src folder.

bash
mkdir src/stores

Integrating Pinia into the Vue Application

Step 3: Update main.ts

Import Pinia and initialize it in your main application file.

src/main.ts
ts
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
import router from './router';

const app = createApp(App);
app.use(createPinia());
app.use(router);
app.mount('#app');

TIP

By calling app.use(createPinia()), we register Pinia as a plugin, making it available throughout the app.

Creating a Movie Store

Step 4: Define the Movie Store

Create a useMovieStore in src/stores/movie.ts.

src/stores/movie.ts
ts
import { defineStore } from 'pinia';
import { ref } from 'vue';

interface Movie {
  id: number;
  title: string;
  description: string;
}

export const useMovieStore = defineStore('movie', () => {
  // State
  const movies = ref<Movie[]>([]);
  const selectedMovie = ref<Movie | null>(null);

  // Actions
  async function fetchMovies() {
    // Simulate API call
    movies.value = [
      { id: 1, title: 'Inception', description: 'A mind-bending thriller.' },
      { id: 2, title: 'The Matrix', description: 'A cyberpunk classic.' },
      { id: 3, title: 'Interstellar', description: 'A journey through space and time.' },
    ];
  }

  function selectMovie(id: number) {
    selectedMovie.value = movies.value.find((movie) => movie.id === id) || null;
  }

  return { movies, selectedMovie, fetchMovies, selectMovie };
});

::: important

  • State: Defined using ref or reactive within the setup function.
  • Actions: Functions that modify the state.
  • Getters: Computed properties derived from the state (we'll cover this later). :::

Using the Store in Components

Step 5: Update Home.vue

Modify Home.vue to use the movie store.

src/views/Home.vue
vue
<template>
  <div>
    <h1>Home Page</h1>
    <button @click="loadMovies">Load Movies</button>
    <ul>
      <li v-for="movie in movies" :key="movie.id">
        <RouterLink :to="`/movie/${movie.id}`">{{ movie.title }}</RouterLink>
      </li>
    </ul>
  </div>
</template>

<script setup lang="ts">
import { onMounted } from 'vue';
import { useMovieStore } from '../stores/movie';

const movieStore = useMovieStore();

const movies = movieStore.movies;

function loadMovies() {
  movieStore.fetchMovies();
}

onMounted(() => {
  if (movies.length === 0) {
    loadMovies();
  }
});
</script>

TIP

  • Access store properties directly via movieStore.movies.
  • Actions like fetchMovies are methods on the store instance.

Step 6: Update MovieDetail.vue

Modify MovieDetail.vue to use the store.

src/views/MovieDetail.vue
vue
<template>
  <div v-if="selectedMovie">
    <h1>{{ selectedMovie.title }}</h1>
    <p>{{ selectedMovie.description }}</p>
    <RouterLink to="/">Back to Home</RouterLink>
  </div>
  <div v-else>
    <p>Loading...</p>
  </div>
</template>

<script setup lang="ts">
import { onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { useMovieStore } from '../stores/movie';

const route = useRoute();
const movieStore = useMovieStore();

const selectedMovie = movieStore.selectedMovie;

onMounted(() => {
  const id = Number(route.params.id);
  if (movieStore.movies.length === 0) {
    movieStore.fetchMovies().then(() => {
      movieStore.selectMovie(id);
    });
  } else {
    movieStore.selectMovie(id);
  }
});
</script>

Pitfall

Ensure you handle cases where the movies might not be loaded yet, especially if navigating directly to the detail page.

Adding Getters to the Store

Step 7: Define Getters

Modify movie.ts to include a getter.

src/stores/movie.ts
ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';

interface Movie {
  id: number;
  title: string;
  description: string;
}

export const useMovieStore = defineStore('movie', () => {
  // State
  const movies = ref<Movie[]>([]);
  const selectedMovie = ref<Movie | null>(null);

  // Getters
  const movieCount = computed(() => movies.value.length);

  // Actions
  async function fetchMovies() {
    // Simulate API call
    movies.value = [
      { id: 1, title: 'Inception', description: 'A mind-bending thriller.' },
      { id: 2, title: 'The Matrix', description: 'A cyberpunk classic.' },
      { id: 3, title: 'Interstellar', description: 'A journey through space and time.' },
    ];
  }

  function selectMovie(id: number) {
    selectedMovie.value = movies.value.find((movie) => movie.id === id) || null;
  }

  return { movies, selectedMovie, movieCount, fetchMovies, selectMovie };
});

Step 8: Use Getters in Components

Use the movieCount getter in Home.vue.

src/views/Home.vue
vue
<template>
  <div>
    <h1>Home Page</h1>
    <p>Total Movies: {{ movieCount }}</p>
    <!-- Rest of the template -->
  </div>
</template>

<script setup lang="ts">
import { onMounted } from 'vue';
import { useMovieStore } from '../stores/movie';

const movieStore = useMovieStore();

const movies = movieStore.movies;
const movieCount = movieStore.movieCount;

function loadMovies() {
  movieStore.fetchMovies();
}

onMounted(() => {
  if (movies.length === 0) {
    loadMovies();
  }
});
</script>

Store Modules

Pinia uses a flat store structure, but you can create multiple stores to handle different parts of your application.

Step 9: Create a User Store

Create a useUserStore in src/stores/user.ts.

src/stores/user.ts
ts
import { defineStore } from 'pinia';
import { ref } from 'vue';

interface User {
  id: number;
  name: string;
}

export const useUserStore = defineStore('user', () => {
  const user = ref<User | null>(null);

  function login(name: string) {
    // Simulate login
    user.value = { id: 1, name };
  }

  function logout() {
    user.value = null;
  }

  return { user, login, logout };
});

Step 10: Use User Store in Components

Use the user store in App.vue.

src/App.vue
vue
<template>
  <div id="app">
    <nav>
      <RouterLink to="/">Home</RouterLink> | <RouterLink to="/about">About</RouterLink>
      <span v-if="user"> | Welcome, {{ user.name }}! <button @click="logout">Logout</button></span>
      <span v-else> | <button @click="login">Login</button></span>
    </nav>
    <RouterView />
  </div>
</template>

<script setup lang="ts">
import { useUserStore } from './stores/user';

const userStore = useUserStore();

const user = userStore.user;

function login() {
  userStore.login('John Doe');
}

function logout() {
  userStore.logout();
}
</script>

Advanced Pinia Features

Step 11: Using Store Plugins

Pinia supports plugins for extending store functionalities.

Create a Logger Plugin

src/plugins/logger.ts
ts
import { PiniaPluginContext } from 'pinia';

export function LoggerPlugin({ store }: PiniaPluginContext) {
  store.$subscribe((mutation, state) => {
    console.log(`[${mutation.storeId}] ${mutation.type}`, mutation.events);
  });
}

Register the Plugin

src/main.ts
ts
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
import router from './router';
import { LoggerPlugin } from './plugins/logger';

const app = createApp(App);
const pinia = createPinia();
pinia.use(LoggerPlugin);

app.use(pinia);
app.use(router);
app.mount('#app');

TIP

Plugins can enhance Pinia stores with additional functionalities like persistence, logging, etc.

Step 12: Persisting State

For persisting state across sessions, you can use third-party plugins like pinia-plugin-persistedstate.

Install the Plugin

bash
npm install pinia-plugin-persistedstate
bash
yarn add pinia-plugin-persistedstate

Register the Plugin

src/main.ts
ts
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import piniaPersist from 'pinia-plugin-persistedstate';
import App from './App.vue';
import router from './router';

const app = createApp(App);
const pinia = createPinia();
pinia.use(piniaPersist);

app.use(pinia);
app.use(router);
app.mount('#app');

Enable Persistence in Store

src/stores/user.ts
ts
export const useUserStore = defineStore('user', () => {
  // ... existing code

  return { user, login, logout };
}, {
  persist: true,
});

Pitfall

Be cautious when persisting sensitive data. Ensure you're not storing sensitive information like passwords in local storage.

Best Practices and Tips

Tip 1: Use Composition API Syntax

Pinia supports both Composition API and Options API syntax. Using the Composition API offers better TypeScript support and a more modern approach.

Tip 2: Organize Stores Logically

Create separate stores for different domains or features of your application, like user, movie, cart, etc.

Tip 3: Avoid Mutating State Outside Actions

While Pinia allows direct state mutation, it's a good practice to mutate state only within actions for better maintainability.

Tip 4: Leverage Getters for Derived State

Use getters to compute properties based on the state. They are cached and reactive.

Common Pitfalls

Pitfall 1: Forgetting to Use ref or reactive

Issue: State properties not being reactive.

Solution: Always initialize state properties with ref or reactive.

ts
const count = ref(0); // Correct
const user = reactive({ name: 'John' }); // Correct

Pitfall 2: Accessing State Before Initialization

Issue: State is undefined when accessed.

Solution: Ensure you initialize and populate state before accessing it. Use lifecycle hooks like onMounted.

Pitfall 3: Circular Dependencies

Issue: Importing one store into another can cause circular dependency issues.

Solution: Refactor your stores to eliminate circular dependencies or use dynamic imports.

Do's and Don'ts

Do's

  • Do use the Composition API syntax for better TypeScript support.
  • Do define clear and concise actions for state mutations.
  • Do use getters for computed properties based on the state.

Don'ts

  • Don't mutate the state outside of actions (preferably).
  • Don't store non-serializable data in the state (e.g., DOM elements).
  • Don't forget to handle errors in your actions, especially asynchronous ones.

Conclusion

By following this guide, you've learned how to:

  • Set up Pinia in a Vue 3 application.
  • Create and use stores with state, getters, and actions.
  • Integrate the store into components using the Composition API.
  • Handle advanced features like plugins and state persistence.
  • Apply best practices and avoid common pitfalls.

Happy Coding! Pinia provides a powerful yet simple way to manage state in your Vue applications. Feel free to explore more of its features and integrate them into your projects.