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.
cd my-vue-app
npm install pinia
yarn add pinia
Step 2: Create a Pinia Store Directory
Create a stores
directory inside your src
folder.
mkdir src/stores
Integrating Pinia into the Vue Application
Step 3: Update main.ts
Import Pinia and initialize it in your main application file.
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
.
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
orreactive
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.
<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.
<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.
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
.
<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
.
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
.
<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
import { PiniaPluginContext } from 'pinia';
export function LoggerPlugin({ store }: PiniaPluginContext) {
store.$subscribe((mutation, state) => {
console.log(`[${mutation.storeId}] ${mutation.type}`, mutation.events);
});
}
Register the Plugin
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
npm install pinia-plugin-persistedstate
yarn add pinia-plugin-persistedstate
Register the Plugin
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
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
.
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.