State Management

Chapter 12
State Management with Pinia, and Data Fetching
The final chapter — global state the official way, plus tying fetching into a store

For state shared across many distant components, Vue's official answer is Pinia. Like Zustand and Redux for React, or a service for Angular, it holds state outside the component tree so any component can reach it — but a Pinia store is built from the very same reactivity primitives you already know (ref, computed), making it feel like a composable that happens to be global.

Defining a Store

// stores/counter.js (npm install pinia) import { defineStore } from 'pinia'; import { ref, computed } from 'vue'; export const useCounterStore = defineStore('counter', () => { const count = ref(0); const double = computed(() => count.value * 2); function increment() { count.value++; } return { count, double, increment }; });

defineStore('counter', () => {...}) — the "setup store" form — is essentially a composable: declare refs (the state), computeds (derived values, called "getters"), and functions (the "actions") inside, and return them. The first argument is a unique store id. If this looks almost identical to a composable from Chapter 10, that's the point — the difference is that a Pinia store is a single shared instance, not fresh state per call.

Setup, and Using the Store

// main.js import { createPinia } from 'pinia'; createApp(App).use(createPinia()).mount('#app');
<!-- any component, anywhere --> <script setup> import { useCounterStore } from './stores/counter'; const store = useCounterStore(); </script> <template> <p>{{ store.count }} (double: {{ store.double }})</p> <button @click="store.increment">+1</button> </template>

Register Pinia once with app.use(createPinia()) (the same plugin pattern as the router). Then any component calls useCounterStore() and accesses store.count, store.double, store.increment — and two completely unrelated components calling it share the exact same state, no props or events between them. This is the same problem Context/Zustand solved in React and a service solved in Angular, here with Vue's own reactivity underneath.

Destructuring a store loses reactivity — use storeToRefs
Writing const { count } = useCounterStore() breaks reactivity — count becomes a disconnected snapshot (the same reactive-destructuring trap from Chapter 2). To pull out reactive state, use storeToRefs: const { count, double } = storeToRefs(store). Actions (functions) can be destructured normally — only state and getters need storeToRefs.

Data Fetching Inside a Store

// stores/users.js export const useUsersStore = defineStore('users', () => { const users = ref([]); const status = ref('idle'); // idle | loading | error | success async function fetchUsers() { status.value = 'loading'; try { const res = await fetch('/api/users'); if (!res.ok) throw new Error('Request failed'); users.value = await res.json(); status.value = 'success'; } catch (e) { status.value = 'error'; } } return { users, status, fetchUsers }; });

An action can be async — so a store is a natural home for API calls, exactly as a service was in Angular (Chapter 11). The status ref drives loading/error/success rendering, the same single-status pattern used across the React projects. A component calls store.fetchUsers() in onMounted and reads store.users/store.status in the template — and because the result lives in the store, several components share the fetched data without re-requesting it, the same benefit React Query gave (React Advanced Chapter 2).

Pinia vs a plain composable for shared state
You could share state with a composable that creates its refs in module scope (outside the function) — and for small cases that works. Pinia adds what a real app wants on top: a single well-defined store id, devtools integration (time-travel, inspecting state), server-side-rendering support, and plugins. Reach for a store when state is genuinely global and app-significant; a plain composable stays fine for localized reusable logic.
Vue (Pinia)ReactAngular
defineStore + useXStore()Zustand create() / ContextInjectable service
state = ref(...)store stateservice properties
getters = computed(...)derived/selectorsgetters / computed
actions = functionsstore actionsservice methods
async action for fetchingReact Query / thunkservice + HttpClient

Coding Challenges

Challenge 1

Set up Pinia and define a counter store (state: count; getter: double; actions: increment, reset). Use it from two unrelated components, confirming both share the same count with nothing passed between them.

📄 View solution
Challenge 2

Build a cart store (state: items; action addToCart with the duplicate-quantity check; getter totalItems) and use it from a product list and a separate cart-display component. Use storeToRefs to read items reactively.

📄 View solution
Challenge 3

Build a users store with an async fetchUsers action and a status ref (idle/loading/error/success). A component calls fetchUsers in onMounted and renders loading/error/success states from the store.

📄 View solution

Chapter 12 Quick Reference

  • Pinia — Vue's official global state; a store is essentially a shared, named composable
  • defineStore('id', () => {...}) — state (ref), getters (computed), actions (functions), then return them
  • Register once with app.use(createPinia()); use via useXStore() in any component
  • Two components using the same store share one instance — no props, no events
  • storeToRefs(store) — destructure state/getters reactively (actions destructure normally)
  • An async action makes a store a natural home for API calls + loading/error/success state
  • Use a store for genuinely global state; a plain composable for localized reusable logic
🎉 That completes the Vue course — all 12 chapters, from the SFC and reactivity through composables, routing, and Pinia.