Pinia in depth and data fetching patterns
Setup versus options stores, getters, actions and plugins, then the loading, error and optimistic-update patterns around a real request.
Two store shapes
// stores/guides.js — setup store: refs in, refs out
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useGuides = defineStore('guides', () => {
const items = ref([])
const filter = ref('all')
const visible = computed(() =>
filter.value === 'all' ? items.value : items.value.filter(g => g.status === filter.value)
)
function setFilter(value) { filter.value = value }
return { items, filter, visible, setFilter }
})// stores/guides.js — the same store in options style
export const useGuides = defineStore('guides', {
state: () => ({ items: [], filter: 'all' }),
getters: {
visible: state => state.filter === 'all'
? state.items
: state.items.filter(g => g.status === state.filter)
},
actions: {
setFilter(value) { this.filter = value }
}
})- Both styles produce the same store. The setup style reuses what you already know from the Composition API and composes naturally with composables; the options style reads closer to Vuex.
statemust be a function so each store instance and each server request gets its own object. A plain object literal would be shared between requests.- The first argument to
defineStoreis a unique id. Reusing an id gives you the same store under two names, silently. - Getters are computed values and are cached; filter in a getter rather than in the template, where a plain function re-runs on every render.
- Call
useGuides()inside setup, inside an action, or inside another store. Pinia creates the instance on first call and returns the same one afterwards.
Actions, patching and plugins
import { storeToRefs } from 'pinia'
import { useGuides } from '@/stores/guides'
const guides = useGuides()
guides.setFilter('draft') // a named action: intent is documented
guides.$patch({ filter: 'draft' }) // several changes in one update
guides.$reset() // back to the initial state
const { items, visible } = storeToRefs(guides) // state and getters stay connected
// persist just what must survive a reload
guides.$subscribe((mutation, state) => {
localStorage.setItem('guides-filter', JSON.stringify(state.filter))
})
// a plugin that runs for every store instance
pinia.use(({ store }) => {
const saved = localStorage.getItem(store.$id)
if (saved) store.$patch(JSON.parse(saved))
})- Prefer a named action over an inline
$patchin a component: the action names the intent and can be tested on its own. - A store can use another store by calling its
usefunction at the top of an action, which keeps initialisation lazy and avoids import cycles. storeToRefsis for state and getters only. Destructuring actions is safe because they are already bound to the store.- Persistence is not built in. A
$subscribeor a plugin is the usual approach — persist only what must survive, and never the loading or error flags. - Pinia devtools shows each store's timeline, which is the quickest way to find what changed a value and when.
⚠️
Persisting an entire store rehydrates stale data on the next visit and can restore one user's cart or profile on a shared machine. Whitelist the keys you persist and clear them on sign-out.
Requests, loading states and optimistic updates
const loading = ref(false)
const error = ref(null)
async function load() {
loading.value = true
error.value = null
try {
const res = await fetch('/api/guides')
if (!res.ok) throw new Error('HTTP ' + res.status)
items.value = await res.json()
} catch (e) {
error.value = e.message
} finally {
loading.value = false
}
}
// optimistic toggle: change the list first, roll back if the write fails
async function toggle(id) {
const item = items.value.find(g => g.id === id)
const before = item.done
item.done = !before
try {
await fetch('/api/guides/' + id, { method: 'PATCH' })
} catch (e) {
item.done = before
error.value = 'Could not save your change'
}
}- Model three states, not one: loading, error, and empty. A list that renders "no results" while it is still loading is the most common bug in a fetch store.
- Keep the request in an action so every caller shares the same flags. A fetch inside a component duplicates the state per instance and refetches on every mount.
- An optimistic update needs three things to be safe: the previous value saved, a rollback on failure, and a way to reconcile with the server's response.
- Guard against stale responses — tag each request and discard the result if a newer one has already landed.
- On the server, await the store action during page render; otherwise the first paint shows the empty state and the browser fills it in afterwards.
FAQ
Why is my store not reactive after destructuring?
Destructuring copies a primitive out of the store. Use
storeToRefs for state and getters, and read actions directly from the store object. In a template you can also just read store.total, which is reactive without any helper.Composable or store for shared state?
A composable with module-scope state is fine for a small app and has no dependency. A store pays off once you need devtools, several stores that depend on each other, per-request state on the server, or plugins for persistence.
Related
Composables and reusable logic Testing Vue components
Last refreshed 2026-09-18.