DEV Community

A0mineTV
A0mineTV

Posted on

Vue 3 + Pinia: Predictable State with Optimistic Updates (and clean rollbacks)

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 };
Enter fullscreen mode Exit fullscreen mode

1. Optimistic Add

To make an addition feel instant, the store follows these steps:

  1. Create a temporary ID on the client.
  2. Push the item into the state immediately with _pending = true.
  3. Call the API in the background.
  4. On success: Replace the _tempId with the real ID from the server and clear the _pending flag.
  5. On failure: Perform a rollback by removing the temporary item from the list.

2. Optimistic Remove

For deletions, the process is equally streamlined:

  1. Remove the item locally from the store state.
  2. Call the API.
  3. 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/apiRemove with 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)
      }
    },
  },
})
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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 (inflight set; optional clientRequestId)
  • [ ] Persistence & resume for _pending actions
  • [ ] 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)