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.