A Pinia $subscribe audit log set up in a preferences panel misses changes after the panel closes, and a write made right after $patch; why, and what fixes each?
answer
- bound to the current scope
- detached keeps it alive
- patch pauses the watcher
- flush affects direct writes only
- sync flush sees every write
basics
~20 sA $subscribe made inside a component is removed when it unmounts unless { detached: true } is passed. And $patch pauses the watcher until the next tick, so a direct write beside it gets no non-sync call; flush: 'sync' reports every write.
solid answer
~50 s`$subscribe` registers its remover with the current effect scope, so a subscription created in the panel's `<script setup>` dies on unmount and writes from the header's theme toggle go unlogged. Pass `{ detached: true }`, or subscribe outside any component after `app.use(pinia)`, and keep the returned remover. The second gap is timing: `$patch` pauses the state watcher, calls every subscriber itself, synchronously, and resumes non-sync listening only after `nextTick()`. With the default flush, a direct write made in the same tick as the patch gets no call of its own: one made just before is folded into the patch's call, one made just after surfaces only in whatever call comes next. `{ flush: 'sync' }` reports each direct write immediately, at the cost of one call per write. The `flush` option never changes how `$patch` notifies: always once, synchronously.
code
vue · 11 lines<script setup lang="ts">
import { usePreferencesStore } from '@/stores/preferences'
import { logChange } from '@/audit'
const prefs = usePreferencesStore()
// Without detached, this subscription is removed when the panel unmounts.
// logChange is a module-level function, so remounting the panel
// re-subscribes the same callback, which Pinia 4 ignores.
prefs.$subscribe(logChange, { detached: true })
</script>go deeper
Remember that a $subscribe made inside a component stops when the component unmounts unless you pass { detached: true }.
Explain that direct writes are reported through a watcher that batches per tick by default, while $patch calls subscribers itself, synchronously.
Diagnose missing audit entries: scope-bound subscriptions, and direct writes folded into a patch in the same tick. Fix with detached, grouped patches or a deliberate sync flush.
Decide where app-wide subscriptions live and who owns their removal, and when per-write fidelity justifies the cost of synchronous callbacks.
## Two symptoms, two mechanisms The audit log is registered with `prefs.$subscribe(logChange)` in the preferences panel's `<script setup>`. Two kinds of entries go missing: 1. After the user closes the panel, a theme change made from the header toggle is never logged. 2. An action that runs `this.$patch({ theme, fontSize })` and then `this.lastSyncedAt = now` logs one entry: the direct write never gets one of its own. They come from different parts of `$subscribe`. ## Lifetime: subscriptions follow the current scope When `$subscribe` is called, Pinia checks for a current **effect scope**. If there is one and `detached` is not `true`, it registers the subscription's remover with `onScopeDispose`. A component's `setup` runs inside the component's scope, so: - the subscription lives exactly as long as the panel; - when the panel unmounts, the remover runs and the callback is deleted; - every write after that, from anywhere, is unobserved. The fixes: - **`prefs.$subscribe(logChange, { detached: true })`** skips the scope binding, so the subscription outlives the component; - or create the subscription **outside any component** (application start-up after `app.use(pinia)`, or a plugin), where there is no current scope to bind to. Either way, keep the function `$subscribe` returns and call it when logging should stop. A detached subscription created on every mount stacks up duplicates only if each mount passes a new function; Pinia 4 ignores re-subscribing the same function, so a module-level callback is also safe from that. ## Timing: what flush controls, and what $patch overrides `$subscribe` is a deep `watch()` on the store's state, and extra options are passed to that watcher. Pinia sets no `flush`, so Vue's default applies: the callback is queued and runs once for all direct writes in the same tick. `$patch` is handled differently: | Write | Default flush | `flush: 'sync'` | |---|---|---| | Direct write, alone in its tick | one `'direct'` call, when Vue next flushes its queue | one `'direct'` call per write, immediately | | `$patch(...)` | one call, synchronously, inside `$patch` | the same | | Direct write in the same tick as a `$patch` | no call of its own | its own `'direct'` call | The third row is the surprise. Inside `$patch`, Pinia switches its listening flags off, applies the change, calls every subscriber itself, then turns synchronous listening back on immediately and non-sync listening back on only after `nextTick()`. A queued non-sync callback that runs before that point is skipped. So, for a direct write in the same tick as a patch: - **just before the patch**: it is folded into the patch's call; the state the callback sees includes it, but the mutation says `'patch object'` and the payload does not list it; - **just after the patch**: the patch's call has already run, so no callback sees it until the next change arrives, which then carries it along. ## Choosing the fix for the timing gap 1. **Put related writes in the patch.** If `lastSyncedAt` belongs to the same logical change, write it inside the same `$patch`. One change, one entry, correctly attributed. 2. **Diff, do not trust the payload.** An audit log that diffs against its own snapshot still records a folded-in field, only under the wrong entry; a write after the patch is recorded late, with the next change. 3. **Use `{ flush: 'sync' }`** when every direct write needs its own entry, on time. Synchronous listening resumes as soon as `$patch` returns, so the write after the patch gets its own call. The cost is one callback per write: a loop of 100 assignments produces 100 calls, and heavy callbacks run in the middle of your code. ## Checklist - Subscriptions for app-wide concerns (audit, persistence) are **detached** or created outside components. - The returned remover is kept by whoever owns the log. - Related writes are grouped in one `$patch` or one action. - `flush: 'sync'` is chosen deliberately, and the callback is cheap when it is. - Nobody expects `flush` to change `$patch`: the typings state that it does not.
- Does { detached: true } change anything when Pinia's $subscribe is called outside a component?Usually no. Pinia only binds the subscription when a current effect scope exists; at application start-up or in a plain module there is none, so the subscription already lives until its remover is called. It matters inside any active scope: a component's setup, a manual `effectScope()`, or another setup store's setup function.
- Why does flush: 'post' not make a Pinia subscriber see $patch changes after rendering?Because `$patch` does not go through the watcher at all. It pauses the watcher-based listener, applies the change and calls every subscriber itself, synchronously, before returning. `flush` configures only that watcher, so it affects direct writes and nothing else, as the `$subscribe` typings note.
saying these in an interview costs you the question
- A $subscribe callback lives as long as the store, wherever it was created.
- The flush option also delays the callbacks that $patch triggers.
- Pinia's $subscribe defaults to flush 'sync', one call per write.
- A direct write right after $patch always gets its own non-sync call.
- flush: 'sync' is free, so every subscription should use it.