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.
  • state must 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 defineStore is 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 $patch in a component: the action names the intent and can be tested on its own.
  • A store can use another store by calling its use function at the top of an action, which keeps initialisation lazy and avoids import cycles.
  • storeToRefs is for state and getters only. Destructuring actions is safe because they are already bound to the store.
  • Persistence is not built in. A $subscribe or 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.

Composables and reusable logic Testing Vue components

Last refreshed 2026-09-18.