Pinia State Management
Defining a Pinia store, using it across components, and a complete auth-store example.
Why Pinia, when composables already share logic
The composables covered on the Composition API page (a function like useCounter() that bundles refs and functions together) already let you extract and reuse stateful logic. But a plain composable creates a new, independent instance of its state every time it's called — perfectly fine for something like a form's local validation logic, but not what you want for state that's genuinely meant to be one single shared source of truth across the whole app, like the currently logged-in user or a shopping cart.
Pinia is Vue's official state management library (the direct successor to the older Vuex), purpose-built for exactly that case: a store defined once, and shared — the same single instance — by every component that uses it, anywhere in the app, with no need to thread it through props or Context-style providers.
npm install pinia
// main.js
import { createApp } from "vue";
import { createPinia } from "pinia";
import App from "./App.vue";
createApp(App).use(createPinia()).mount("#app");
Defining a store
defineStore takes a unique ID and a setup function that looks exactly like a <script setup> block — refs become state, computeds become getters, and plain functions become actions:
// stores/counter.js
import { ref, computed } from "vue";
import { defineStore } from "pinia";
export const useCounterStore = defineStore("counter", () => {
const count = ref(0);
const isEven = computed(() => count.value % 2 === 0);
function increment() {
count.value++;
}
function reset() {
count.value = 0;
}
return { count, isEven, increment, reset };
});
This is Pinia's setup store syntax — the modern, recommended way to define a store, since it's the same mental model as a composable (state as refs, derived values as computeds, methods as plain functions) with one crucial difference: calling useCounterStore() anywhere in the app returns the same store instance every time, not a fresh independent one.
Using a store in a component
<!-- CounterDisplay.vue -->
<script setup>
import { useCounterStore } from "../stores/counter";
const counterStore = useCounterStore();
</script>
<template>
<p>Count: {{ counterStore.count }} ({{ counterStore.isEven ? "even" : "odd" }})</p>
</template>
<!-- CounterButtons.vue -->
<script setup>
import { useCounterStore } from "../stores/counter";
const counterStore = useCounterStore();
</script>
<template>
<button @click="counterStore.increment()">+1</button>
<button @click="counterStore.reset()">Reset</button>
</template>
CounterDisplay and CounterButtons don't need to be related in the component tree at all — both call useCounterStore() and get the exact same shared instance, so clicking a button in one immediately updates what the other displays. This is the same practical problem Context solves in React, but with less ceremony: no provider component to wrap around the tree, just import the store and call its function.
Destructuring a store's state directly (const { count } = useCounterStore()) loses reactivity, for the same reason destructuring a reactive() object does — use Pinia's storeToRefs helper when you need individual reactive references out of a store:
<script setup>
import { storeToRefs } from "pinia";
import { useCounterStore } from "../stores/counter";
const counterStore = useCounterStore();
const { count, isEven } = storeToRefs(counterStore); // reactive, unlike plain destructuring
const { increment, reset } = counterStore; // actions are plain functions — safe to destructure directly
</script>
A complete example: an auth store
A more realistic store typically wraps an actual API call and tracks loading/error state alongside the data itself — the same pattern as a useFetch composable, but shared globally instead of per-component:
// stores/auth.js
import { ref, computed } from "vue";
import { defineStore } from "pinia";
export const useAuthStore = defineStore("auth", () => {
const user = ref(null);
const loading = ref(false);
const error = ref(null);
const isLoggedIn = computed(() => user.value !== null);
async function login(credentials) {
loading.value = true;
error.value = null;
try {
const response = await fetch("/api/login", {
method: "POST",
body: JSON.stringify(credentials),
headers: { "Content-Type": "application/json" },
});
if (!response.ok) throw new Error("Invalid credentials");
user.value = await response.json();
} catch (err) {
error.value = err.message;
throw err;
} finally {
loading.value = false;
}
}
function logout() {
user.value = null;
}
return { user, loading, error, isLoggedIn, login, logout };
});
<!-- LoginForm.vue -->
<script setup>
import { ref } from "vue";
import { useAuthStore } from "../stores/auth";
const authStore = useAuthStore();
const email = ref("");
const password = ref("");
async function handleSubmit() {
try {
await authStore.login({ email: email.value, password: password.value });
} catch {
// authStore.error is already set — the template below reads it directly
}
}
</script>
<template>
<form @submit.prevent="handleSubmit">
<input v-model="email" type="email" placeholder="Email" />
<input v-model="password" type="password" placeholder="Password" />
<button type="submit" :disabled="authStore.loading">Log in</button>
<p v-if="authStore.error">{{ authStore.error }}</p>
</form>
</template>
<!-- NavBar.vue -->
<script setup>
import { useAuthStore } from "../stores/auth";
const authStore = useAuthStore();
</script>
<template>
<nav>
<span v-if="authStore.isLoggedIn">Welcome, {{ authStore.user.name }}</span>
<button v-if="authStore.isLoggedIn" @click="authStore.logout()">Log out</button>
</nav>
</template>
LoginForm and NavBar are almost certainly nowhere near each other in the component tree, yet both read from and react to the exact same useAuthStore() instance — this cross-cutting, anywhere-in-the-app sharing is precisely the problem Pinia (and libraries like it in other frameworks — Redux/Zustand in React, for instance) exists to solve.
Common mistakes
- Destructuring a store's state or getters directly (
const { count } = useCounterStore()) instead of usingstoreToRefs— this copies out the current value once and disconnects it from future reactivity, the same trap as destructuring a plainreactive()object. - Reaching for a Pinia store for state that's really only local to one component or a small subtree — plain
ref/reactivelocal state, or a composable, is simpler and doesn't need a globally shared instance. - Defining a store with the same
idstring used elsewhere by accident — Pinia identifies each store by its ID, so a collision silently causes two unrelated stores to share state. - Putting an API call's loading/error handling ad hoc in every component that happens to trigger it, instead of centralizing that logic once inside the store's own action — duplicating the same try/catch/finally pattern across components is exactly what a store action is meant to avoid.