Why does Pinia have no mutations when Vuex routed every state change through commit, and what replaced them?
answer
- what devtools once depended on
- sync-only layer under actions
- both kinds of write are tracked
- $patch groups several changes
- actions may be async
basics
~10 sVuex 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 sIn 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 linesimport { 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
Recall that a Pinia store has only state, getters and actions, and that state can be changed directly or inside actions.
Explain what Vuex mutations were for, why devtools no longer needs them, and how $patch groups several changes into one entry.
Decide where writes may happen in your codebase: without mutations, enforcement becomes a review or lint convention, and actions hold multi-field changes.
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.