Declaring State with ref: What Actually Shows Up in $state
From Creating Pinia Stores you know that a setup store returns refs and Pinia turns them into state. Now look closely at what that transformation does, because two surprises wait there: value unwrapping, and the difference between the reactive proxy you use and the raw object devtools and persistence layers see.
import { ref, reactive, defineStore } from 'pinia'
export const useCartStore = defineStore('cart', () => {
const items = ref<{ id: string; qty: number }[]>([])
const coupon = ref<string | null>(null)
const meta = reactive({ openedAt: Date.now(), source: 'web' })
return { items, coupon, meta }
})
On the store instance, Pinia unwraps refs the same way templates do: you read cart.items, not cart.items.value. Assignments work through the same proxy — cart.coupon = 'SAVE10' writes through to the underlying ref. Forgetting the unwrap is the most common beginner error in plain composables; with stores it usually shows up in TypeScript, where store.coupon is typed as string | null rather than Ref<string | null>, and your editor tells you immediately.
$state is the second surface. Every setup store exposes a $state property that is a reactive object mirroring the state fields:
| Expression | What it is | Typical use |
|---|---|---|
cart.items |
unwrapped ref on the store proxy | templates, actions, tests |
cart.$state.items |
same value through the $state proxy | wholesale reads, serialization |
toRaw(cart.$state) |
plain object snapshot | logging, sending over HTTP |
structuredClone(cart.$state) |
deep value copy | freezing a snapshot for a test |
Serialization is where this pays off: JSON.stringify(cart.$state) gives you every state field and nothing else — no methods, no computeds, no $id. That is exactly the payload SSR hydration and localStorage persistence will store, and it is why state fields must stay JSON-serializable. A ref holding a Map, a class instance, or a File will silently degrade to {} or throw on revival. Keep state as plain data — arrays, objects, strings, numbers, booleans — and push anything exotic into a non-returned local or a module-level variable.
Two structural decisions round out state design. ref suits primitive and replaced-wholesale values (a token, a counter, an array you reassign); reactive suits a nested object you mutate field by field, though you must return the whole object — destructuring a reactive into separate store exports breaks its proxy identity. And nested mutation has a cost beginners underestimate: cart.items.push(...) inside an action is fine, but deep mutations from components bypass the naming story devtools tells, which is the discipline Actions for Async Work is built to restore.
Finally, $reset() exists but only for options stores — it re-runs the state factory. A setup store has no factory to re-run, so it has no $reset; you either implement function reset() { items.value = [] ... } inside the store and return it, or use $patch to clear fields. Knowing which of the two a codebase uses tells you how it was written before you read a single component.
When destructuring a store in a setup component, use storeToRefs for state and getters. Actions are functions and can be destructured directly. A computed that returns a new object still has identity semantics, so avoid recreating large derived structures unless the view needs them.
Getters as Computeds: Caching, the $state Parameter, and Getter-to-Getter Access
A getter is a computed that lives in the store, and it inherits computed semantics exactly: the value is computed lazily, cached, and recomputed only when a reactive dependency changes. Reading cart.total five times in one render calls the getter function once; pushing an item recomputes it once; the next five reads hit the cache again. If you find yourself memoizing derived values by hand inside store methods, delete the memoization and make it a getter.
In the setup syntax a getter is just a computed you return:
export const useCartStore = defineStore('cart', () => {
const items = ref<Item[]>([])
const total = computed(() =>
items.value.reduce((sum, i) => sum + i.price * i.qty, 0)
)
const expensiveCount = computed(() =>
items.value.filter((i) => i.price > 100).length
)
return { items, total, expensiveCount }
})
The options syntax offers two ways to write the same thing, and the difference is a favorite interview question:
getters: {
// (a) state parameter — recommended for typing
total: (state) => state.items.reduce((s, i) => s + i.price * i.qty, 0),
// (b) this — needed only when reading OTHER getters
expensiveTotal(this: ReturnType<typeof useCartStore>) {
return this.total * 1.1
},
}
Form (a) receives state as its argument, which TypeScript types automatically from your state factory — no this annotations, no inference helper. But that parameter contains only state fields: a state-parameter getter cannot see expensiveTotal or any other getter. Form (b) binds this to the full store and can read state, getters, and actions, at the cost of often needing an explicit this type in TypeScript. The rule of thumb: write getters with the state parameter, and drop to this only for getter-to-getter dependencies.
Setup stores sidestep the whole question — closures over the returned computeds work directly, and since a setup store's total is itself a computed ref, expensiveTotal can simply reference total.value in its own function body. Either way, chains of computeds invalidate correctly and in one pass when the underlying state changes.
A caching nuance worth carrying into reviews: a computed's cache exists per reference. If a getter returns state.items.filter(...), the array it returns is a fresh array each recompute, so a watch on that getter fires whenever items change — correct. But if a getter returns a reactive object from state rather than a new value, it returns the same reference every time, and watchers keyed on reference equality will not fire even though fields inside it changed. Returning derived primitives (numbers, strings, booleans) or freshly built arrays/objects keeps invalidation predictable.
One last guardrail: getters must stay pure. Reading localStorage, calling fetch, or mutating state inside a getter makes the cache lie about what it contains and can loop forever during invalidation. Impure work belongs in actions, which is the next stop after $patch semantics.
For large state trees, split state by domain and keep actions that enforce invariants close to the fields they update. A getter should derive a value, not mutate state. Test it with representative empty, loading, and populated states.
$patch: Partial Updates, Batching, and Why Wholesale $state Replacement Is Different
Mutating fields one at a time works, but Pinia offers $patch for updating several fields in one operation, and the reasons it exists are exactly the ones interviewers probe: batching, single-event semantics, and safe partial updates.
const cart = useCartStore()
// (a) partial object — merges shallowly into $state
cart.$patch({ coupon: 'SAVE10' })
// (b) function form — mutate however you like, one patch event
cart.$patch((state) => {
state.items.push({ id: 'a1', price: 40, qty: 2 })
state.coupon = 'SAVE10'
state.meta.source = 'email-campaign'
})
The object form is a shallow merge: state: () => ({ meta: { openedAt: 0, source: 'web' } }) patched with { meta: { source: 'app' } } replaces the entire meta object, losing openedAt. Arrays are replaced whole, not concatenated. When a patch touches nested fields, use the function form, which receives the live reactive state and lets you mutate any depth.
Why not just assign each field directly? Three reasons make $patch the preferred tool. First, one patch produces one event: devtools logs a single grouped entry instead of four scattered mutations, and — more importantly — $subscribe and persistence plugins are notified once per patch rather than once per field, so a save-to-storage subscriber writes once instead of four times. Second, the function form executes synchronously against the state, so readers between two statements never see a half-updated combination that never existed in your model. Third, patches are composable: a form component can build a patch payload, and the store applies it in one named operation.
Compare that with the wholesale alternative:
cart.$state = { items: [], coupon: null, meta: { openedAt: 0, source: 'web' } }
This replaces every field — it is the setup-store equivalent of $reset(), and it demands that you supply every property; miss one and it becomes undefined. It also notifies subscribers once but rewrites everything, so persistence serializes the whole object even if you only changed a coupon. Use $patch for targeted updates; reserve $state = ... for full resets.
It is equally important to know what Pinia does not guarantee: direct mutations (cart.coupon = 'X') are allowed — Pinia tracks them for devtools just fine — but there is no transactional rollback and no enforcement of where they come from. Some teams wrap $state in readonly() to make accidental outside mutation a loud type error; others adopt the softer rule that components may read anything but only actions and $patch may write. That rule needs a mutation entry point with a name on it, and that entry point is the subject of Actions for Async Work.
A getter should expose a stable meaning to consumers. If it depends on a list, define how empty values, duplicates, and sorting are handled. For expensive derivations, computed caching helps while dependencies are unchanged, but it does not make a costly recomputation free when every mutation invalidates the source.
Test the getter with a changing collection and verify consumers update without copying derived values into another mutable field. Keep the result understandable to component authors.
Quiz
1. A setup store declares `const coupon = ref<string | null>(null)` and returns it. How does component code read and write it?
2. An options store's state is `() => ({ meta: { openedAt: 0, source: 'web' }, coupon: null })`. After `store.$patch({ meta: { source: 'app' } })`, what is store.meta?
3. In an options store, why would you write `total: (state) => state.items.reduce(...)` instead of using `this.items`?
4. A persistence plugin saves the store to localStorage on every change. A checkout flow updates six state fields in sequence. What is the best way to apply those updates?
Flashcards
Question
How do you read and write a ref returned from a setup store, and where does .value apply?
Click to reveal answer
Answer
On the store instance you use the unwrapped value: store.coupon reads and store.coupon = 'X' assigns. .value is only needed inside the store file where you hold the raw ref itself; Pinia's proxy unwraps refs for consumers, like templates do.
Question
What caching semantics do store getters have?
Click to reveal answer
Answer
Identical to component computeds: evaluated lazily, cached, and recomputed only when a reactive dependency changes. Reading a getter many times per render runs its function once, and mutations to unrelated state do not invalidate it.
Question
What is the $state parameter in options-store getters, and what can it NOT see?
Click to reveal answer
Answer
It is the first argument of a getter written as total: (state) => ..., automatically typed from the state factory. It exposes only state fields — not other getters, not actions — so getter-to-getter dependencies require accessing the getter through this.
Question
Object-form $patch vs function-form $patch: what is the merge behavior of each?
Click to reveal answer
Answer
Object form is a shallow merge into $state — nested objects and arrays are replaced whole, not deep-merged. Function form receives the live reactive state and lets you mutate any depth; both forms emit a single notification to subscribers and devtools.
Question
How do you reset a setup store, given that $reset() is unavailable?
Click to reveal answer
Answer
$reset() only exists for options stores because it re-runs the state factory. A setup store must either expose its own reset() function that reassigns each ref, or you simulate it with cart.$state = { ...initial } or a $patch clearing every field.
Revision Notes
Key Takeaways
- 1. Refs returned from a setup store are unwrapped on the store proxy — store.coupon is the value, and $state mirrors the same fields for serialization
- 2. State must stay JSON-serializable because $state is what SSR hydration and persistence plugins persist
- 3. Getters are computeds with identical lazy caching; write options-store getters with the auto-typed state parameter and use this only for getter-to-getter reads
- 4. $patch emits one event per call — batch related field changes so subscribers and persistence write once, and use the function form for nested edits
- 5. $reset() is options-store only; setup stores need an explicit reset() or a full $state replacement
Interview Tips
- • "Explain computed caching in a store" — lazy evaluation, cache invalidated only by reactive dependencies; add the nuance that returning the same reactive reference makes reference-keyed watchers silent
- • "state parameter vs this in getters?" — state param is auto-typed and sees only state; this sees the whole store but often needs manual typing in TS
- • "When would you use $patch over direct assignment?" — batching related changes into one subscriber/devtools event and avoiding observable half-updated states
- • "What happens on $patch({ meta: { source: 'app' } }) with nested state?" — shallow merge replaces the whole nested object; the function form is the fix
Cheat Sheet
State and Getters
Reading state:
| Expression | Meaning |
|---|---|
store.coupon |
unwrapped ref value (use in templates/components) |
store.$state |
reactive mirror of all state fields |
JSON.stringify(store.$state) |
serializable snapshot — keep state JSON-safe |
Getters:
| Style | Form | Sees |
|---|---|---|
| Setup | computed(() => ...) returned |
closure refs/computeds |
| Options, preferred | total: (state) => ... |
state only (auto-typed) |
| Options, fallback | total() { return this.total * 1.1 } |
state + getters + actions |
Updates:
store.$patch({ coupon: 'SAVE10' }) // shallow merge, one event
store.$patch((s) => { s.items.push(i) }) // deep-safe, one event
store.$state = { ...allFields } // wholesale replace (= reset)
Shallow-merge warning: nested objects/arrays in object-form patches are replaced whole. Getters stay pure; impure work belongs in actions.