In a Pinia store for a user-preferences panel, when do you write state directly, call $patch with an object, or call $patch with a function?
answer
- no mutations layer
- grouping several writes
- object form merges recursively
- arrays replaced, not merged
- function form for push and splice
basics
~20 sDirect writes suit single fields and v-model. $patch with an object applies several fields at once, merging nested objects but replacing arrays. $patch with a function mutates state in place for array edits or logic; both patch forms notify subscribers once.
solid answer
~40 sPinia state is writable from anywhere: `prefs.theme = 'dark'` or `v-model="prefs.fontSize"` is a tracked change that devtools records and `$subscribe` reports with type `'direct'`; there is no mutations layer. `$patch({ theme: 'dark', fontSize: 16 })` applies several fields as one grouped change: plain nested objects are merged key by key, so `{ notifications: { push: false } }` keeps `email`, but arrays are replaced whole. That makes list edits awkward, so `$patch((state) => { state.mutedChannels.push('promo') })` hands you the state to mutate in place. Either patch form produces one devtools entry and one synchronous subscriber call, typed `'patch object'` (with the payload) or `'patch function'`. The function must be synchronous; the types reject an `async` one.
code
ts · 18 linesimport { defineStore } from 'pinia'
export const usePreferencesStore = defineStore('preferences', {
state: () => ({
theme: 'system' as 'light' | 'dark' | 'system',
fontSize: 14,
notifications: { email: true, push: true },
mutedChannels: [] as string[],
}),
})
const prefs = usePreferencesStore()
prefs.theme = 'dark' // direct: type 'direct'
prefs.$patch({ fontSize: 16, notifications: { push: false } }) // email kept
prefs.$patch((state) => {
state.mutedChannels.push('promo') // in place, one change
})go deeper
Know the three forms: assign a field directly, $patch with an object for several fields, $patch with a function for array edits. Say that no mutations layer is needed.
Explain the object form's merge: plain nested objects merge key by key, arrays are replaced whole. Say why the function form exists and that both forms notify subscribers once.
Choose the form by how the change should be grouped and reported, route named domain changes through actions, and warn that an async patch function silently splits into ungrouped writes.
Decide whether the team allows direct writes from components or funnels them through actions, weighing auditability and one grouped change against form ergonomics with v-model.
## Three ways to write Pinia state Pinia has **no mutations layer**: any code that holds a store can change its state. It offers three ways to do it. Take a preferences store whose `state()` returns `theme`, `fontSize`, `notifications: { email, push }` and `mutedChannels: string[]`. | Write | Example | What a `$subscribe` callback sees | Pick it when | |---|---|---|---| | Direct write | `prefs.theme = 'dark'` | type `'direct'` | one field, or `v-model` on a form control | | `$patch(object)` | `prefs.$patch({ theme: 'dark', fontSize: 16 })` | type `'patch object'` plus `payload` | several fields you can express as values | | `$patch(function)` | `prefs.$patch((s) => { s.mutedChannels.push('promo') })` | type `'patch function'` | array edits, conditions, values computed from current state | ## Direct writes A **direct write** assigns through the store: `prefs.theme = 'dark'`, `prefs.mutedChannels.push('promo')`, or `v-model="prefs.fontSize"` in a template. It is an ordinary reactive change: - components that read the field re-render; - devtools records it, and it can be time-travelled just like a patch; - `$subscribe` callbacks receive a mutation of type `'direct'`; with the default (non-sync) flush, several direct writes in the same tick arrive as a single call. You can only write keys that `state()` declared. Assigning an undeclared key is a TypeScript error, and at runtime it only becomes a plain property of the store object, not part of the state. ## `$patch` with an object: a recursive merge `$patch(partialState)` takes a partial object and merges it into the state as **one grouped change**: - **plain nested objects are merged key by key**: `prefs.$patch({ notifications: { push: false } })` sets `push` and leaves `email` alone; - **arrays and other non-plain values are assigned whole**: `prefs.$patch({ mutedChannels: ['promo'] })` replaces the list; it does not append; - subscribers are called **once and synchronously**, with `type: 'patch object'` and the object you passed as `mutation.payload`; - devtools shows the whole patch as **one entry** rather than one per field. The array rule is the practical limit. To add one channel with the object form you must build a new array (`[...prefs.mutedChannels, 'promo']`); removing one needs a `filter`. ## `$patch` with a function `$patch((state) => { … })` calls your function with the store's state and lets you **mutate it in place**: `push`, `splice`, conditional writes, loops. Everything it does is grouped into one change and reported to subscribers once with `type: 'patch function'`. There is no `payload`, because Pinia never sees a patch object, only the result of your code. The function must be **synchronous**. The `$patch` signature resolves to `never` when the function returns a `Promise`, so TypeScript rejects an `async` function. At runtime only the writes made before the first `await` would land inside the patch; later ones happen afterwards as ordinary direct writes, outside the grouped change. Do the asynchronous work in an action, then patch with the result. ## Choosing between them 1. One field, or a form control bound with `v-model`: write directly. 2. Several fields that form one logical change, such as applying a preset (theme plus font size): `$patch` with an object, so subscribers and devtools see a single entry. 3. Anything that edits an array, or reads the current state to decide what to write: `$patch` with a function. 4. A change that has a name in your domain, such as "mute a channel": wrap the write in an action, which itself uses one of the three forms. None of the three is more reactive than the others; they differ in **how the change is grouped and reported**. A typical preferences panel mixes them: `v-model` on each input, a function patch for muting a channel, an object patch when a preset is applied. ## Mistakes interviewers listen for - Believing state may only change inside actions: that is a convention some teams adopt, not a Pinia rule. - Expecting `$patch({ list: [...] })` to append: arrays are always replaced. - Expecting `$patch({ nested: { one: 1 } })` to wipe the other nested keys: plain objects are merged. - Passing an `async` function to `$patch` and assuming every awaited write is grouped.
- What happens if you pass an async function to a Pinia store's $patch?TypeScript rejects it: the `$patch` signature resolves to `never` when the function returns a `Promise`. At runtime only the writes before the first `await` would run inside the grouped patch; anything after it happens later as ordinary direct writes, outside the grouped change and its single notification. Do the async work in an action and patch with the result once it resolves.
- Does a Pinia store require writes to go through actions?No. Any code holding the store may write its state directly or through `$patch`. Actions exist for logic you want named, reusable and observable, not as a gate on writes. A team that wants a single write path enforces it by convention or code review; Pinia itself does not.
saying these in an interview costs you the question
- Pinia state can only be changed inside actions, like mutations in Vuex.
- $patch with an object appends to arrays instead of replacing them.
- $patch with an object replaces nested objects and drops keys you did not pass.
- Direct writes are invisible to devtools; only $patch is tracked.
- An async function passed to $patch groups every write it makes after await.