TL;DR — Keep your business logic in a Pinia store.
Do the UI update optimistically (immediately), then confirm or rollback when the API responds.
Centralize concurrency, persistence, and tests in one place. Your components stay simple; your state stays predictable.
Why Move Logic to a Pinia Store ?
When each component manages its own state, logic spreads out: duplicate edge‑cases, inconsistent error handling, and hard‑to‑reproduce bugs.
A Pinia store is a single source of truth where add/remove flows, API calls, concurrency guards, and persistence are centralized and testable.
The Anatomy of an Optimistic Store
A minimal store designed for this pattern should include:
- items: Your main domain data (e.g.,
Item[]). - pending: A counter for ongoing network actions.
- Per-item flags: Specifically
_pending(to show a spinner while awaiting
type Item = { id: string; name: string; price: number; _pending?: boolean };
type State = { items: Item[]; pending: number };
1. Optimistic Add
To make an addition feel instant, the store follows these steps:
- Create a temporary ID on the client.
- Push the item into the state immediately with
_pending = true. - Call the API in the background.
- On success: Replace the
_tempIdwith the real ID from the server and clear the_pendingflag. - On failure: Perform a rollback by removing the temporary item from the list.
2. Optimistic Remove
For deletions, the process is equally streamlined:
- Remove the item locally from the store state.
- Call the API.
- On failure: Perform a rollback by re-inserting the item back into the state.
Advanced Reliability: Concurrency & Persistence
Handling Concurrency
To avoid double submissions (like clicking an "Add" button twice), keep a Set of "in-flight" identifiers [4]. You can also send a clientRequestId to your API so the server can deduplicate retries.
Persistence & Resume
Use a plugin like pinia-plugin-persistedstate to keep state across refreshes. On startup, your app can then replay actions that are still marked as _pending or re-fetch data to reconcile the client and server states.
Testing the Logic
By keeping flows inside Pinia, you can use Vitest and MSW to cover your logic without a browser. Essential tests include:
- Add success/failure: Verifying temp-to-real ID transitions and rollbacks.
- Remove rollback: Ensuring data returns if the API fails.
- Concurrency: Testing that the in-flight guard blocks double adds.
- Persistence: Ensuring pending items survive a page reload.
Store snippet (Pinia, TypeScript)
Focused on the core logic. Replace
apiAdd/apiRemovewith your real calls.
// stores/cart.ts
import { defineStore } from 'pinia'
type Item = { id: string; name: string; price: number; _pending?: boolean }
type NewItem = { name: string; price: number }
async function apiAdd(p: NewItem): Promise<{ id: string }> {
// simulate real API — replace with your client (fetch/axios)
await new Promise(r => setTimeout(r, 400))
return { id: crypto.randomUUID() }
}
async function apiRemove(id: string): Promise<void> {
await new Promise(r => setTimeout(r, 250))
}
export const useCartStore = defineStore('cart', {
state: () => ({
items: [] as Item[],
pending: 0,
inflight: new Set<string>(), // guard for duplicates
}),
getters: {
isBusy: s => s.pending > 0,
total: s => s.items.reduce((a, i) => a + i.price, 0),
},
actions: {
async addOptimistic(p: NewItem) {
const tempId = crypto.randomUUID()
const optimistic: Item = { id: tempId, ...p, _pending: true }
if (this.inflight.has(p.name)) return // minimal dedupe example
this.inflight.add(p.name)
this.items.push(optimistic)
this.pending++
try {
const { id } = await apiAdd(p)
const idx = this.items.findIndex(x => x.id === tempId)
if (idx !== -1) this.items[idx] = { ...this.items[idx], id, _pending: false }
} catch {
this.items = this.items.filter(x => x.id !== tempId) // rollback
} finally {
this.pending = Math.max(0, this.pending - 1)
this.inflight.delete(p.name)
}
},
async removeOptimistic(id: string) {
const snapshot = this.items.find(i => i.id === id)
if (!snapshot) return
this.items = this.items.filter(i => i.id !== id)
this.pending++
try {
await apiRemove(id)
} catch {
// rollback
this.items.push(snapshot)
} finally {
this.pending = Math.max(0, this.pending - 1)
}
},
},
})
Minimal component usage (no styling)
One button to add a sample item; a simple list to show
_pending.
<!-- components/CartDemo.vue -->
<template>
<section :aria-busy="store.isBusy">
<h2>Cart ({{ store.items.length }}) — {{ store.total.toFixed(2) }} €</h2>
<ul>
<li v-for="it in store.items" :key="it.id">
{{ it.name }} — {{ it.price.toFixed(2) }} €
<em v-if="it._pending"> (saving…)</em>
</li>
</ul>
<button :disabled="store.isBusy" @click="add()">Add sample</button>
</section>
</template>
<script setup lang="ts">
import { useCartStore } from '@/stores/cart'
const store = useCartStore()
function add() {
store.addOptimistic({ name: 'Coffee Beans 250g', price: 7.90 })
}
</script>
Production notes
- Centralize messages & errors in the store for consistency (optionally expose a small event bus if needed).
- Add in‑store backoff or rate‑limits if your API has quotas.
- Emit analytics events from the store (success, failure, rollback) to understand real‑world behavior.
Copy‑paste checklist
- [ ] Single source of truth in Pinia (no component‑local duplications)
- [ ] Clear state:
items,pending, item flags (_pending,_tempId) - [ ] Optimistic add/remove with clean rollback paths
- [ ] Concurrency guard (
inflightset; optionalclientRequestId) - [ ] Persistence & resume for
_pendingactions - [ ] Tests: add success/failure, remove rollback, concurrency, persistence
Wrap‑up
Optimistic updates make your app feel instant without scattering logic across components. By keeping flows inside a Pinia store, you get predictable state, reusable business logic, and components that focus on rendering. Start with the minimal pattern above, add guards/persistence, and cover it with a few high‑value tests—done.
Top comments (0)