← All guides

Pinia stores + fetch, measured β€” the destructure that freezes your UI, the getter that fetched 469 times, and the 500 that renders as a product

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.

1. Try it in 10 seconds (no signup)

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.

⚑ Skip the terminal: this link creates a live, seeded e-commerce backend (products, orders, customers, reviews) in the dashboard β€” real URL, no signup. Or import your own OpenAPI spec, db.json, CSV, Postman collection, or HAR and mock your exact shapes.

2. The store everyone writes first

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.

3. The destructure that freezes your UI (and why it sometimes "works")

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.

4. The getter that fetched 469 times

"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.

5. The race where nothing looks broken

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.

6. The 500 that renders as a product

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.

7. Loading and error states you can actually see

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.

8. $reset() works on one store syntax and throws on the other

Options-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.

9. What $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:

Mutationdefault flush{ flush: 'sync' }
Action doing 3 assignments (this.a++; this.b++; this.c++)1 callback3 callbacks
3 direct assignments outside an action1 callback3 callbacks
store.$patch({ a, b, c })1 callbackstill 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.

10. Deterministic retry drills with mock_seq

Retry 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.

11. Get your own API (10 seconds, no signup)

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.

12. Honest comparison

ToolGood atWhere this differs
Pinia Colada / TanStack Query (Vue)Purpose-built async layer: caching, dedup, retries, abort handled for youGenuinely 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.
MSWIn-process interception for unit testsMSW 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/testingcreateTestingPinia stubs actions for component unit testsIt 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-serverLocal fake REST, huge ecosystemNeeds 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).