State Management in Vue 3 with Pinia: The Successor to Vuex

by Didin J. on Aug 05, 2025 State Management in Vue 3 with Pinia: The Successor to Vuex

Learn how to manage state in Vue 3 apps using Pinia—the official Vuex successor. This guide covers setup, stores, persistence, testing, and migration tips.

State management has always been a critical part of building complex Vue.js applications. For years, Vuex has been the official state management library for Vue, offering a centralized way to manage and share data across components. However, as Vue evolved—especially with the introduction of the Composition API in Vue 3—Vuex's boilerplate-heavy approach and steep learning curve started to show limitations.

Enter Pinia, the new official state management library for Vue 3. Designed to be simpler, more intuitive, and fully compatible with both the Composition API and TypeScript, Pinia has quickly become the recommended choice for managing application state in modern Vue apps. It retains the powerful concepts of Vuex—like centralized state, actions, and getters—but wraps them in a much cleaner and developer-friendly API.

In this tutorial, you'll learn how to set up and use Pinia in a Vue 3 project. We'll explore how to define stores, use them in components, persist state, test your stores, and even migrate from Vuex. Whether you're building a small app or a large-scale SPA, mastering Pinia will make your state management simpler and more scalable.


What is Pinia?

Pinia is the official state management library for Vue 3, designed as a modern alternative to Vuex. While it shares similar core concepts—like state, getters, and actions—Pinia embraces simplicity, modularity, and a zero-boilerplate philosophy. It was built from the ground up to work seamlessly with the Composition API and is fully compatible with TypeScript.

✅ Why Pinia Over Vuex?

Here are some of the key reasons why Pinia has become the preferred choice for Vue 3:

  • Zero boilerplate: No need for separate mutations—just update state directly within actions.

  • Modular stores: Easily split logic into multiple smaller stores.

  • TypeScript-friendly: Full type inference with minimal setup.

  • Devtools integration: Full support for Vue Devtools, including time-travel debugging.

  • Lightweight and intuitive: Smaller bundle size and easier to learn.

🔁 Key Differences Between Pinia and Vuex

Feature Vuex Pinia
Boilerplate High Low
Mutations Required Not used
Modular Stores Clunky (namespaced) Native support
Composition API Workarounds needed Built-in support
TypeScript Support Verbose Fully type-safe and inferred
Official Status Deprecated in favor of Pinia Official Vue 3 state manager

Pinia’s API is designed to feel like plain JavaScript—no special decorators or weird syntax required. With a shallow learning curve and powerful capabilities, it's now the go-to solution for managing complex state in modern Vue apps.


Setting Up the Project

Before diving into Pinia, we need to create a fresh Vue 3 project and install the required dependencies. For this tutorial, we'll use Vite for a fast and modern development environment, but you can also use Vue CLI if preferred.

🛠 Step 1: Create a Vue 3 Project with Vite

If you don’t have Vite installed globally, run:

npm create vite@latest pinia-demo -- --template vue
cd pinia-demo
npm install

Or with yarn:

yarn create vite pinia-demo --template vue
cd pinia-demo
yarn

This will scaffold a new Vue 3 app using the Vite + Vue template.

📦 Step 2: Install Pinia

Now, install Pinia with the following command:

npm install pinia

or

yarn add pinia

🧩 Step 3: Register Pinia in Your App

Open main.js or main.ts and set up Pinia:

import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'

const app = createApp(App)

const pinia = createPinia()
app.use(pinia)

app.mount('#app')

📁 Step 4: Organize the Store Directory

Create a directory to store all your Pinia stores:

mkdir src/stores

This helps keep your project organized as it grows.

✅ At this point, you’re ready to start creating and using Pinia stores in your Vue 3 project.


Creating Your First Pinia Store

Now that Pinia is installed and registered, let's create your first store to manage application state. We'll build a simple counter store to demonstrate the basics of Pinia’s API: state, getters, and actions.

📁 Step 1: Create a New Store File

Inside your src/stores directory, create a new file called counter.js (or counter.ts if you're using TypeScript):

import { defineStore } from 'pinia'

export const useCounterStore = defineStore('counter', {
  state: () => ({
    count: 0
  }),

  getters: {
    doubleCount: (state) => state.count * 2
  },

  actions: {
    increment() {
      this.count++
    },
    decrement() {
      this.count--
    },
    incrementBy(amount) {
      this.count += amount
    }
  }
})

🧠 Key Concepts

  • defineStore(): Creates a new Pinia store. The first parameter is a unique name (used in devtools and SSR).

  • state(): A function that returns the reactive state object.

  • getters: Computed properties based on the state.

  • actions: Functions that can mutate the state and handle business logic (both sync and async).

Note: There’s no need for mutations like in Vuex. You can directly modify the state inside actions.


Using the Store in Components

Now that you've created your first Pinia store, let’s see how to use it in Vue components to read and update state.

📥 Step 1: Import and Use the Store

In any Vue component, you can import your store and use it inside the setup() function (Composition API) or in data()/methods() if you're using the Options API.

Here's an example using the Composition API:

<!-- src/components/Counter.vue -->
<template>
  <div>
    <h2>Counter: {{ counter.count }}</h2>
    <p>Double: {{ counter.doubleCount }}</p>
    <button @click="counter.increment">Increment</button>
    <button @click="counter.decrement">Decrement</button>
    <button @click="counter.incrementBy(5)">Increment by 5</button>
  </div>
</template>

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

const counter = useCounterStore()
</script>

🧠 Explanation

  • useCounterStore() returns a reactive store instance.

  • You can access and modify state directly (counter.count).

  • Getters like doubleCount behave like computed properties.

  • Actions are just methods (counter.increment()).

✅ Example with Options API

If you're still using the Options API, you can do this instead:

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

export default {
  name: 'CounterOptions',
  computed: {
    counter() {
      return useCounterStore()
    },
    count() {
      return this.counter.count
    },
    double() {
      return this.counter.doubleCount
    }
  },
  methods: {
    increment() {
      this.counter.increment()
    }
  }
}
</script>

While Pinia is designed to work best with the Composition API, it still supports the Options API for legacy codebases.


Reactivity and DevTools Integration

One of the strongest features of Pinia is its seamless integration with Vue’s reactivity system and the Vue Devtools. This makes debugging and state tracking much easier and more intuitive than ever before.

🧬 Fully Reactive Store

Pinia stores are reactive by default, just like component state. That means:

  • Changes to the state will automatically update the UI.

  • You can use state, getters, and actions anywhere in your app, and they'll remain reactive.

For example:

const counter = useCounterStore()

watch(
  () => counter.count,
  (newVal) => {
    console.log(`Count changed to: ${newVal}`)
  }
)

This will respond to changes in counter.count just like any Vue reactive property.

🔧 Vue Devtools Integration

Pinia integrates smoothly with Vue Devtools, giving you powerful debugging tools:

  • View all defined stores and their current state.

  • See history of mutations/actions via Time Travel Debugging.

  • Trigger or replay actions.

  • Inspect and edit state directly in the devtools.

To Use Devtools:

Make sure you have the latest Vue Devtools extension installed for Chrome or Firefox.

Once running:

  1. Open your Vue app in the browser.

  2. Open Devtools → Go to the "Pinia" tab.

  3. Explore your store state, getters, and action calls.

If Pinia doesn’t appear:

  • Ensure createPinia() is installed before mounting the app.

  • Make sure you're in development mode (not in a production build).


Modular Stores and Best Practices

As your application grows, having a single monolithic store can quickly become unmanageable. Pinia encourages a modular architecture by design, making it easy to split state management into logical, self-contained stores.

🧩 Creating Multiple Stores

You can organize your application into feature-specific stores. For example:

src/
├── stores/
│   ├── counter.js
│   ├── auth.js
│   └── product.js

Example: auth.js Store

// src/stores/auth.js
import { defineStore } from 'pinia'

export const useAuthStore = defineStore('auth', {
  state: () => ({
    user: null,
    token: null,
  }),
  actions: {
    login(userData) {
      this.user = userData.user
      this.token = userData.token
    },
    logout() {
      this.user = null
      this.token = null
    }
  }
})

You can then use it anywhere like this:

import { useAuthStore } from '@/stores/auth'

const auth = useAuthStore()
auth.login({ user: { name: 'Jane' }, token: 'abc123' })

✅ Best Practices

Here are some tips for organizing and scaling your Pinia stores:

  • Group by domain: Keep each store focused on a single domain (e.g., auth, cart, user, etc.)

  • Avoid global mutations: Use actions to encapsulate logic and ensure clarity.

  • Use TypeScript where possible: Pinia works great with types and offers full auto-completion.

  • Keep state minimal: Don’t store derived values — use getters instead.

  • Lazy-load stores if needed: For large apps with code splitting.


Using Pinia with Composition API and Options API

Pinia is designed to be Composition API-first, but it also supports the Options API to maintain compatibility with existing Vue codebases. Here’s how you can use it in both styles.

🧪 Composition API (Recommended)

When using the Composition API, stores are simply imported and called within the setup() function (or <script setup> block), and they’re fully reactive.

Example:

<script setup>
import { useAuthStore } from '../stores/auth'

const auth = useAuthStore()

function logoutUser() {
  auth.logout()
}
</script>

You can directly access and modify store state, call actions, and use getters as if they were reactive variables or functions.

Pros:

  • Cleaner and more modular

  • Better TypeScript support

  • Works perfectly with <script setup>

🧩 Options API

Using Pinia in the Options API is still possible but requires some workarounds, particularly when accessing store state in computed and store actions in methods.

Example:

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

export default {
  name: 'CounterOptions',
  computed: {
    counter() {
      return useCounterStore()
    },
    count() {
      return this.counter.count
    }
  },
  methods: {
    increment() {
      this.counter.increment()
    }
  }
}
</script>

Pros:

  • Maintains legacy compatibility

  • Useful in projects still using the Options API

⚠️ Cons:

  • Slightly more verbose

  • Less TypeScript-friendly

🔍 When to Use Which?

Scenario Recommended API
New Vue 3 project Composition API
Existing Vue 2 or early Vue 3 project Options API
TypeScript support needed Composition API


Persisting State in Pinia

By default, Pinia stores are in-memory—meaning the state is lost when the page is refreshed. However, you can easily persist your store state using plugins like pinia-plugin-persistedstate.

This is useful for persisting:

  • Auth tokens

  • User preferences

  • Shopping carts

  • Theme settings

📦 Step 1: Install the Plugin

npm install pinia-plugin-persistedstate

or

yarn add pinia-plugin-persistedstate

⚙️ Step 2: Register the Plugin

In main.js (or main.ts):

import { createApp } from 'vue'
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'

import App from './App.vue'

const app = createApp(App)

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

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

🗃️ Step 3: Enable Persistence in a Store

Modify a store to enable persistence using the persist option:

import { defineStore } from 'pinia'

export const useAuthStore = defineStore('auth', {
  state: () => ({
    user: null,
    token: null,
  }),
  actions: {
    login(userData) {
      this.user = userData.user
      this.token = userData.token
    },
    logout() {
      this.user = null
      this.token = null
    }
  },
  persist: true
})

This will store your data in localStorage by default.

🛠 Custom Storage and Configuration

You can also customize how and where data is stored:

persist: {
  key: 'my-auth',
  storage: sessionStorage,
  paths: ['token'] // Only persist token, not user
}

Now your store’s state will survive page reloads — a must-have for modern web apps.


Testing Pinia Stores

Testing your state management logic is essential for building reliable Vue applications. With Pinia, testing is straightforward because stores are just plain JavaScript modules — no magic, no mocks required (unless you want them).

You can test:

  • Store state directly

  • Actions, including side effects

  • Getters, like computed properties

🧰 Example: Testing a Simple Counter Store

Assume the following counter.js store:

import { defineStore } from 'pinia'

export const useCounterStore = defineStore('counter', {
  state: () => ({ count: 0 }),
  getters: {
    doubleCount: (state) => state.count * 2
  },
  actions: {
    increment() {
      this.count++
    }
  }
})

🧪 Step 1: Set Up a Test File

Create a test file (e.g., counter.spec.js) using your test framework (Jest, Vitest, etc.):

// tests/stores/counter.spec.js
import { setActivePinia, createPinia } from 'pinia'
import { useCounterStore } from '../../src/stores/counter'

describe('Counter Store', () => {
  beforeEach(() => {
    setActivePinia(createPinia())
  })

  it('should have initial count of 0', () => {
    const counter = useCounterStore()
    expect(counter.count).toBe(0)
  })

  it('should increment the count', () => {
    const counter = useCounterStore()
    counter.increment()
    expect(counter.count).toBe(1)
  })

  it('should return doubleCount correctly', () => {
    const counter = useCounterStore()
    counter.count = 2
    expect(counter.doubleCount).toBe(4)
  })
})

🧼 Tips for Testing Pinia Stores

  • Use setActivePinia(createPinia()) before each test to initialize a fresh store.

  • You can directly access and modify the store state.

  • Actions are just methods — no need to mock them unless testing components in isolation.

✅ If you’re testing components that use stores, you can still inject stores normally as part of your test setup (e.g., with mount() in Vue Test Utils).


Migrating from Vuex to Pinia

If you have an existing Vue 3 application using Vuex, moving to Pinia is a worthwhile upgrade. Pinia offers a simpler API, full TypeScript support, and better integration with the Composition API.

Fortunately, many Vuex concepts map directly to Pinia — but with much less boilerplate.

🔄 Vuex to Pinia Mapping

Vuex Concept Pinia Equivalent
state state
getters getters
mutations ⛔ Not needed
actions actions (combined logic)
modules Separate store files
mapState/mapGetters Direct access or helper composables

✏️ Example Migration: From Vuex to Pinia

Vuex Store (Old)

// store/index.js
export default {
  state: {
    count: 0
  },
  getters: {
    doubleCount: (state) => state.count * 2
  },
  mutations: {
    increment(state) {
      state.count++
    }
  },
  actions: {
    incrementAsync({ commit }) {
      setTimeout(() => commit('increment'), 1000)
    }
  }
}

Pinia Store (New)

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

export const useCounterStore = defineStore('counter', {
  state: () => ({ count: 0 }),
  getters: {
    doubleCount: (state) => state.count * 2
  },
  actions: {
    increment() {
      this.count++
    },
    incrementAsync() {
      setTimeout(() => this.increment(), 1000)
    }
  }
})

✅ Much cleaner. No need for a separate mutations. You update the state directly in actions.

🛠 Migration Tips

  1. Modularize your Vuex store: Split your Vuex store into modules if it’s not already — this makes it easier to convert to multiple Pinia stores.

  2. Replace Vuex helpers: Replace mapState, mapGetters, etc., with direct store usage (via useXStore()).

  3. Update component logic: Refactor component logic to use Pinia’s Composition API-friendly syntax.

  4. Test after each step: Migrate incrementally and verify functionality as you go.

Note: Vuex 5 was in alpha and never finalized — Pinia is now the officially recommended solution.


Conclusion

Pinia has redefined state management in Vue 3 by offering a simpler, cleaner, and more powerful alternative to Vuex. With its intuitive API, tight integration with the Composition API, and excellent TypeScript support, Pinia makes managing application state a much more enjoyable experience — whether you're building a small component or a large-scale SPA.

In this tutorial, you learned how to:

  • Set up and configure Pinia in a Vue 3 project

  • Create and use modular stores with state, getters, and actions

  • Integrate Pinia with components using both the Composition and Options APIs

  • Persist store state using localStorage or sessionStorage

  • Test Pinia stores effectively

  • Migrate from Vuex to Pinia with minimal effort

By embracing Pinia, you're investing in the future of state management in Vue, backed officially by the Vue team. Whether you're building a new project or upgrading an existing one, Pinia is a smart, modern choice to keep your state predictable and maintainable.

You can find the full source code on our GitHub.

===

That's just the basics. If you need more deep learning about Vue, you can take the following cheap course:

Thanks!