Pinia is Vue's official store, and its happy path is genuinely short: defineStore, an action that fetches, a component that reads. But the four most common ways teams wire fetch into a store each hide a failure mode β and the worst three are completely silent.
Everything below was reproduced and measured in a real Chrome on Pinia 4.0.3 + Vue 3.5.43 (Vite, Composition API) with an instrumented window.fetch before publishing. The numbers β a UI frozen at items: 0 over a store holding 30 records, 112 fetches by the 5-second mark from a getter, an error body rendered as a product row, exactly 1 aborted request with the race fix β are observed counts, not estimates.
A shared, self-resetting demo project is live right now:
curl "https://mockbird.mockbird.workers.dev/m/demo/products?limit=3"
That URL is the API for every snippet below. CORS is open (Access-Control-Allow-Origin: *), so it works from localhost:5173, StackBlitz, CodeSandbox, CI β anywhere. When you want your own schema it's one curl or one click β see Β§11.
import { defineStore } from 'pinia'
const API = 'https://mockbird.mockbird.workers.dev/m/demo'
export const useProducts = defineStore('products', {
state: () => ({ items: [] }),
actions: {
async load() {
const res = await fetch(`${API}/products?limit=30`)
this.items = await res.json()
}
}
})
Nothing wrong with it. Every trap below is in how it gets consumed β or in the small "improvements" that come next.
The docs warn that destructuring a store breaks reactivity. Here is what that actually looks like, measured:
setup() {
const store = useProducts()
const { items } = store // β the destructure
store.load()
return { items }
}
Measured: the action fired 1 fetch and the store ended up holding 30 records (store.items.length === 30, verified from the console) β while the template rendered items: 0 with zero <li> rows and zero console warnings or errors. The data arrived; the UI will never show it. this.items = await res.json() pointed the store at a new array, and your destructured binding still holds the old empty one.
The evil part is that the identical destructure appears to work if the action mutates in place:
// same destructure, action does: for (const p of data) this.items.push(p)
Measured: UI rendered items: 30 and 30 rows. The destructured binding is the same reactive array object, so in-place mutation still tracks. That's why this bug bites intermittently: it depends on whether whoever wrote the action used assignment or push that week. One refactor from push loop to this.items = data and every destructuring component silently freezes.
The fix is storeToRefs (state and getters only β actions can be destructured freely):
import { storeToRefs } from 'pinia'
const store = useProducts()
const { items } = storeToRefs(store) // survives reassignment
store.load()
Measured: 1 fetch, items: 30, 30 rows β with the same reassigning action that froze the plain destructure.
"The list might be empty on first read β I'll just make the getter top it up." A getter is a computed; a computed that side-effect-fetches and the action it calls rewrites the state the getter depends on:
getters: {
visible(state) {
this.refill() // side effect in a getter
return state.items
}
},
actions: {
async refill() {
const res = await fetch(`${API}/products?limit=5`)
this.items = await res.json() // invalidates the getter β re-render β refill() againβ¦
}
}
Measured: 43 fetches in the first 2 seconds, 112 fetches by the 5-second mark, 469 requests total in a 20-second run β with zero console output and a perfectly innocent-looking UI stuck on "showing: 5". Every response reassigns items, which invalidates the getter, which re-renders the component, which reads the getter, which fetches again. Nothing in Vue or Pinia warns, because each individual step is legal. Keep getters pure; fetch from actions, called from components or route guards. (Our watchEffect guide measures the sibling storm β same disease, different host.)
Storms like this are invisible against a mock that always answers in 8 ms from memory β and expensive against a real API. Each Mockbird project has a request inspector, so the difference between "1 request" and "469 requests" is a table you can read.
Naive category switcher β the action any of us would write:
actions: {
async byCat(cat) {
const res = await fetch(`${API}/products?category=${cat}`)
this.items = await res.json()
this.cat = cat // β also set after the await
}
}
Click beauty (slow β we pinned ?mock_delay=1500 on it), then toys 150 ms later. Toys answers fast; beauty's stale response lands last and clobbers it.
Measured: final UI showed category: beauty and a beauty list β 0 aborted requests. Note what makes this nastier than the classic version: because this.cat is also set after the await, the header and the list agree with each other. The screen is self-consistent and simply shows the wrong category β the one the user clicked first, not last. No mismatch to catch your eye, nothing in the console, unreproducible on fast Wi-Fi.
Fix inside the store β one module-level AbortController:
let ctrl = null
// in the action:
if (ctrl) ctrl.abort()
ctrl = new AbortController()
try {
const res = await fetch(url, { signal: ctrl.signal })
this.items = await res.json()
this.cat = cat
} catch (e) { if (e.name !== 'AbortError') throw e }
Measured: same click pattern β UI showed category: toys with the toys list and exactly 1 aborted request (the stale beauty fetch). The two mock parameters that make the race reproducible on demand: ?mock_delay=1500 on the first request, nothing on the second.
fetch does not reject on HTTP errors, and res.json() happily parses an error body. So the no-res.ok action doesn't crash on a 500 β it does something worse:
async load() {
const res = await fetch(`${API}/products?limit=5&mock_status=500`)
this.items = await res.json() // 500 body becomes "the list"
}
Measured: the store's items became an object (not an array), the length read rendered as blank (items: ), and v-for β which iterates objects β rendered exactly one row whose text was the API's error body: simulated 500 error (mock_status). Zero console errors. Your "product list" is now displaying an error message as merchandise, and no error state anywhere knows about it.
The boring fix, measured working: check res.ok, keep items an array, set an error field (HTTP 500 rendered in the error slot; items stayed []). The point of ?mock_status=500 is that you can open your real UI on the failure branch right now instead of waiting for production to show you.
actions: {
async load(extra = '') {
this.loading = true
this.error = ''
try {
const res = await fetch(`${API}/products?limit=5${extra}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
this.items = await res.json()
} catch (e) {
this.error = e.message
} finally {
this.loading = false
}
}
}
store.load('&mock_delay=2000') // two full seconds of skeleton
store.load('&mock_status=500') // the error branch, on demand
Measured: with ?mock_delay=2000 the loading element was present at the 300 ms probe and gone after the delay with items: 5; with ?mock_status=500 the error branch rendered HTTP 500. Every skeleton, spinner and toast becomes a URL parameter instead of a DevTools throttling session.
$reset() works on one store syntax and throws on the otherOptions-syntax stores get $reset() for free. Setup-syntax stores (defineStore('x', () => {...})) do not, and the failure is at least loud. Measured verbatim on Pinia 4.0.3:
π: Store "setupstyle" is built using the setup syntax and does not implement $reset().
On the options store, measured: load 5 items β $reset() β items.length === 0, back to the state() factory's values. If your team mixes both syntaxes, a generic "reset all stores on logout" loop will throw on the first setup store it meets β either implement your own $reset action in setup stores or standardize the syntax.
$subscribe actually counts (measured)If you persist state or log audit trails from $subscribe, the callback count is the difference between one write and three. All five cases, measured on Pinia 4.0.3:
| Mutation | default flush | { flush: 'sync' } |
|---|---|---|
Action doing 3 assignments (this.a++; this.b++; this.c++) | 1 callback | 3 callbacks |
| 3 direct assignments outside an action | 1 callback | 3 callbacks |
store.$patch({ a, b, c }) | 1 callback | still 1 callback |
Two practical consequences: the default flush batches per tick, so a persistence plugin sees one snapshot per burst (good); and if you need per-mutation granularity with flush: 'sync', $patch is the only way to make a multi-field write atomic β three bare assignments will fire your subscriber three times with two intermediate states.
mock_seqRetry code is the least-tested code in most apps because you can't make a real API fail twice then succeed. mock_seq makes failure sequences deterministic:
async load() {
const key = 'k' + Date.now() // fresh key per drill
for (let attempt = 1; attempt <= 3; attempt++) {
const res = await fetch(
`${API}/products?limit=3&mock_seq=503,503,200&mock_seq_key=${key}`)
if (res.ok) { this.items = await res.json(); return }
await new Promise(r => setTimeout(r, 250 * attempt))
}
this.error = 'gave up'
}
Measured: exactly 3 requests β 503, 503, then 200 β and the list rendered 3 items after the third. The sequence is tracked per mock_seq_key (and per client), so parallel test workers don't steal each other's failures.
curl -X POST https://mockbird.mockbird.workers.dev/api/projects \
-H "Content-Type: application/json" \
-d '{"preset": "ecommerce"}'
The response contains your project URL and admin key. Presets: blog, ecommerce, saas, payments β or define your own resources and field types, import an OpenAPI spec / db.json / CSV / Postman collection / HAR, and swap the base URL in the snippets above. Writes persist (build optimistic-update actions against reality), GET /m/<project>/types.ts generates TypeScript interfaces for your store state (?format=zod for Zod), and POST /m/<project>/auth/login returns a real signed JWT for testing auth flows (guide). Ship day is one environment token change.
| Tool | Good at | Where this differs |
|---|---|---|
| Pinia Colada / TanStack Query (Vue) | Purpose-built async layer: caching, dedup, retries, abort handled for you | Genuinely the right call for heavy data fetching β most of Β§5's race and Β§10's retry come free. This guide is for the majority of codebases that wire fetch into plain Pinia actions by hand; the traps above are what that costs, measured. Either way you still need an API to point at. |
| MSW | In-process interception for unit tests | MSW mocks live inside each test runner. A hosted mock is the same URL for your browser, CI, a StackBlitz repro, and a teammate's machine. Use MSW for unit tests, a hosted mock for everything shared. |
| @pinia/testing | createTestingPinia stubs actions for component unit tests | It tests components with the store faked. This guide's bugs (frozen destructures, getter storms, races) live in the storeβfetch wiring β stubbed actions can't reproduce any of them. |
| json-server | Local fake REST, huge ecosystem | Needs Node running everywhere the API is needed. Mockbird speaks the same conventions (_page, _limit, db.json import/export) but is hosted β plus auth simulation, failure injection (mock_seq, mock_status, mock_delay), snapshots. |
Bias disclosure: this comparison is written by the Mockbird side. The measurements above, though, are just measurements β rerun them in your own Chrome with the snippets as written. Related: Mock API for Vue Β· watchEffect + fetch, measured Β· Pinia Colada, measured Β· Zustand, measured (the same bugs in React clothing).