5 Best Practices for Scalable Vue.js State Management with Pinia

5 Best Practices for Scalable Vue.js State Management with Pinia

Discover the secrets to maintaining, scaling, and simplifying your Pinia stores with these 5 best practices. Learn how to design modular stores, utilize getters effectively, compose actions wisely, leverage plugins, and optimize data fetching for a more efficient state management solution.

Eduardo San Martin Morote

Eduardo San Martin Morote

July 29, 2024

Who doesn’t like Pinia stores that are more maintainable, scalable, and easy to use. In this article, we share 5 best practices to help you achieve it!

1. Embrace Modular Store Design

Instead of creating a monolithic store, break your state into smaller, focused stores. This approach improves code organization, makes it easier to manage complex state logic, and enhances performance by loading stores only when needed.

// userStore.js
export const useUserStore = defineStore("user", {
  state: () => ({
    user: null,
    preferences: {},
  }),
  // actions and getters...
});

// cartStore.js
export const useCartStore = defineStore("cart", {
  state: () => ({
    items: [],
    total: 0,
  }),
  // actions and getters...
});

2. Leverage Getters only for Computed State

Use getters to derive state instead of storing computed values. This keeps your state lean and ensures derived data is always up-to-date. Though fundamental, it’s crucial to consistently apply this practice.

export const useCartStore = defineStore("cart", {
  state: () => ({
    items: [],
  }),
  getters: {
    // ✅ Getters and computed properties are really the same concept
    total: (state) => state.items.reduce((sum, item) => sum + item.price, 0),
    itemCount: (state) => state.items.length,
    // ❌ avoid just returning state
    itemList: (state) => state.items,
  },
});

3. Implement Action Composition

While composing multiple actions can promote code reuse, it’s important to avoid splitting actions into smaller ones unless they are needed individually. Overuse of this pattern can lead to complex and less maintainable code.

export const useCheckoutStore = defineStore("checkout", {
  actions: {
    // ❌ Splitting `completeCheckout()` into multiple actions
    // can lead to confusion among team mates and end up in other team members
    // using the wrong actions
    async validateCart() {
      /* ... */
    },
    async processPayment() {
      /* ... */
    },
    // ✅ only `completeCheckout()` is used in the application code
    async completeCheckout() {
      await this.validateCart();
      await this.processPayment();
      // Additional checkout logic...
    },
  },
});

If needed, you can split actions into smaller plain functions for readability.

4. Utilize Plugins for Cross-Cutting Concerns

Pinia plugins are powerful tools for adding global functionality to your stores. Here's an example using the popular pinia-plugin-persistedstate for state persistence:

import { createPinia } from "pinia";
import piniaPluginPersistedstate from "pinia-plugin-persistedstate";

const pinia = createPinia();
pinia.use(piniaPluginPersistedstate);

// In your store
export const useUserStore = defineStore("user", {
  state: () => ({
    user: null,
    preferences: {},
  }),
  persist: true,
});

You can also create custom plugins for specific needs, such as logging:

export function loggingPlugin({ store }) {
  store.$subscribe((mutation, state) => {
    console.log(`[${store.$id}]: ${mutation.type}`, mutation.payload);
  });
}

// In your main.js
pinia.use(loggingPlugin);

5. Leverage Parallel Fetching and Coherent State Updates

Data loaders excel at managing complex data fetching scenarios. By default, they enable parallel fetching for independent data sources, significantly improving load times. Additionally, they ensure coherent state updates by delaying all data updates until all loaders have resolved, preventing inconsistent UI states during refetching.

import { defineLoader } from "@unjs/urouter";
import { useQuery } from "@tanstack/vue-query";

export const useUserLoader = defineLoader({
  async loader() {
    const { data: user } = await useQuery(["user"], fetchUser);
    return { user };
  },
});

export const useProductsLoader = defineLoader({
  async loader() {
    const { data: products } = await useQuery(["products"], fetchProducts);
    return { products };
  },
});

// In your component or page
const { user } = useUserLoader();
const { products } = useProductsLoader();

In this example, user and product data are fetched in parallel, and the component won't render until both are available, ensuring a consistent view.

Conclusion

By following these best practices, you'll be well on your way to creating scalable, maintainable state management solutions with Pinia.

Remember, as I highlighted in my Frontend Nation talk, the key to effective state management is finding the right balance between simplicity and power. Pinia offers both, and these practices will help you make the most of it.

The Mastering Pinia Course is Here!

Get a free lesson delivered to your inbox, just click on the button below.

Buy Now