skip to content

Why does Pinia have no mutations when Vuex routed every state change through commit, and what replaced them?

level: middleimportance: must knowfreq 52%

answer

  1. what devtools once depended on
  2. sync-only layer under actions
  3. both kinds of write are tracked
  4. $patch groups several changes
  5. actions may be async

basics

~10 s

Vuex mutations existed so devtools could record each synchronous state change. Pinia's devtools integration records direct writes and $patch calls itself, so mutations were dropped; state now changes through actions, direct assignment or $patch.

solid answer

~40 s

In Vuex, a state change had to be a synchronous mutation invoked with `commit`, while actions did async work and then committed; that split let devtools log each change. Pinia's documentation calls mutations extremely verbose and notes that their devtools reason no longer applies: both direct writes such as `cart.couponCode = 'SPRING'` and `$patch()` calls are tracked and can be time-travelled. So a Pinia store has only state, getters and actions. Actions can be sync or async and write through `this`; components may assign state directly; `$patch` groups several changes into one devtools entry and one subscription notification. Whether writes happen only inside actions is now a team convention, not something the library enforces.

code

ts · 18 lines
ts
import { defineStore } from 'pinia'

export const useCartStore = defineStore('cart', {
  state: () => ({ items: [] as CartItem[], couponCode: '' }),
  actions: {
    // was the Vuex mutation add(state, item)
    add(item: CartItem) {
      this.items.push(item)
    },
    // was a Vuex action that committed add after the fetch
    async addById(id: number) {
      this.add(await api.items.load(id))
    },
  },
})

// a component may also write directly:
// useCartStore().couponCode = 'SPRING'

go deeper

for a junior

Recall that a Pinia store has only state, getters and actions, and that state can be changed directly or inside actions.

for a middle

Explain what Vuex mutations were for, why devtools no longer needs them, and how $patch groups several changes into one entry.

for a senior

Decide where writes may happen in your codebase: without mutations, enforcement becomes a review or lint convention, and actions hold multi-field changes.

for a principal

Weigh freedom against traceability: direct writes speed small features, but a large team may still route writes through actions to keep a stable, searchable surface.

## What a mutation was for In **Vuex**, the store's state could be changed in a sanctioned way only by a **mutation**: a synchronous function receiving `state` and a payload, invoked by name with `commit('cart/add', item)`. **Actions** were a separate layer: they received a `context` object, could run async code, and finished by committing mutations. The reason for that split was tooling. Because every change passed through a named, synchronous function, devtools could record a before-and-after snapshot per mutation and replay history. The cost was ceremony: many modules had one mutation per field and an action whose only job was to commit it. A single user operation such as "apply coupon" could touch an action, two mutations, a getter and a string constant for each name, spread over one module file, and every caller had to spell the namespaced path correctly because nothing checked it at compile time. ## What Pinia tracks instead Pinia's documentation describes mutations as often perceived as extremely verbose, and says the devtools integration they brought is no longer a reason to keep them. In Pinia: - a **direct write** from a component or an action, such as `cart.items.push(item)`, is tracked by devtools; - a **`$patch()` call**, with an object or a function, is tracked too and appears as **one** entry however many fields it changes; - both kinds of change can be **time-travelled** in devtools; - **actions** appear on the devtools timeline as their own events. With recording handled at the state level, the mutation layer had nothing left to do, so a Pinia store has exactly three parts: **state**, **getters** and **actions**. ## Where the old mutation code goes The migration guide gives each Vuex mutation one of two destinations, plus a built-in for one common case: 1. **Turn it into an action.** Drop the `state` parameter and write through `this` instead: `add(state, item) { state.items.push(item) }` becomes `add(item) { this.items.push(item) }`. 2. **Delete it and assign directly.** A mutation that only set one field can disappear; callers write `cart.couponCode = code`. 3. **Use `$reset()` for reset mutations.** A mutation that restored initial state is built in as `$reset()` — but only on **option stores**; a setup store has to define its own reset action. The Vuex actions that committed those mutations lose their `context` argument and call the new actions or assign state through `this`. An action that awaited an API call and then committed now simply awaits and assigns. ## Keeping writes disciplined without mutations Removing the enforced funnel does not remove the reasons some teams wanted one. Common conventions in migrated codebases: - **Multi-field or async changes live in actions**, so the operation has a name, one place to change, and a hook point for `$onAction` observers. - **Single-field, form-style writes** may happen directly in components; that is the use case direct writes were kept for. - **`$patch` is used when several fields must change together**, so subscribers and devtools see one change instead of several. - **Reviews or lint rules**, not the library, decide whether a component may write a given store. ## A worked conversion ```ts // before (Vuex): mutations setSaving, clear + action checkout({ commit }) // after (Pinia): export const useCartStore = defineStore('cart', { state: () => ({ items: [] as CartItem[], couponCode: '', saving: false }), actions: { async checkout() { this.saving = true try { await api.orders.create(this.items) this.$patch({ items: [], couponCode: '' }) } finally { this.saving = false } }, }, }) ``` Three former mutations became plain assignments and one `$patch`; the action is async and writes state across an `await`, which a Vuex mutation could not do. ## Common misreadings | Claim | Reality in Pinia 4 | |---|---| | Only action writes reach devtools | direct writes and `$patch` are both tracked | | Every write must use `$patch` | `$patch` is for grouping; single writes are fine | | Async actions must end with a commit | there is no commit; actions assign through `this` | | Components can no longer change state | they can, directly or by calling actions | The short version for an interview: mutations were a tooling workaround, the tooling moved, and what replaced them is ordinary code plus a convention about where writes belong.

  • If components may write Pinia state directly, why still put a change in an action?
    An action names the operation, keeps multi-field or async changes in one place, can be observed with `$onAction`, and can be stubbed in tests. Direct writes suit a single field such as a form value; logic that must stay consistent belongs in an action.
  • Does dropping mutations make Pinia state changes harder to debug than Vuex's?
    Not in practice: devtools records direct writes and `$patch` calls with time travel, and actions show up as their own timeline events. What you lose is a single enforced funnel for writes; teams restore it by convention, keeping multi-step changes in named actions.

saying these in an interview costs you the question

  • Direct writes from components are invisible to Pinia devtools.
  • Every Pinia state change has to go through $patch.
  • Async Pinia actions must still commit at the end.
  • Without mutations, components cannot change Pinia state at all.
  • Pinia converts each action into a hidden mutation behind the scenes.