skip to content

A Pinia setup store for a cart keeps a `couponCode` ref out of its return object to make it private; what breaks, and what should you do instead?

level: seniorimportance: should knowfreq 41%

answer

  1. what the return object decides
  2. the state tree sees returned refs
  3. serialisation, devtools, plugins
  4. readonly breaks it too
  5. a second internal store

basics

~20 s

Pinia treats only returned refs and reactive objects as state, so an unreturned couponCode is missing from pinia.state: it is not serialised for SSR and is invisible to devtools, $subscribe and plugins. Return every state ref; put internals in a separate store.

solid answer

~40 s

Pinia decides what is state by walking the object a setup store **returns**: refs and reactive objects are linked into `pinia.state`, computeds become getters, functions become actions. An unreturned `couponCode` still works inside the store's own actions, which hides the bug, but it never enters the state tree. So server rendering does not serialise it and the client starts from the initial value, devtools cannot show it, `$patch` and `$subscribe` never touch it, and plugins that read `$state` miss it. The docs are explicit: return all state; private or readonly state breaks SSR, devtools and plugins. Return the ref and enforce action-only writes by review, or move genuinely internal state into a second store the cart store uses.

code

ts · 13 lines
ts
import { ref } from 'vue'
import { defineStore } from 'pinia'

export const useCartStore = defineStore('cart', () => {
  const items = ref<string[]>([])
  const couponCode = ref<string | null>(null) // never returned

  function applyCoupon(code: string) {
    couponCode.value = code // works here, but Pinia cannot see it
  }

  return { items, applyCoupon }
})

go deeper

for a junior

Recall the rule: a setup store must return every ref that holds its data, because only returned refs and reactive objects count as state.

for a middle

Explain how Pinia classifies returned values, and which features read the state tree: SSR serialisation, devtools, $patch, $subscribe and plugins.

for a senior

Diagnose the symptom, such as a coupon lost after hydration or missing from devtools, trace it to an unreturned ref, and offer the internal-store pattern instead of readonly.

for a principal

Decide how the team enforces encapsulation when the runtime cannot: conventions, review rules and internal stores, versus the cost of exposing everything as public state.

## How Pinia reads a setup store's return object A setup store is the function you pass to `defineStore('cart', () => { … })`. Pinia runs it once, inside an effect scope, then walks the object it **returns** and sorts every value: | Returned value | Pinia treats it as | Linked into `pinia.state`? | |---|---|---| | `ref()` or `shallowRef()` | state | yes | | `reactive()` object | state | yes | | `computed()` | a getter | no | | function | an action, wrapped for `$onAction` | no | | plain value such as `0.2` | an ordinary property copied onto the store | no | State is special because Pinia links each returned ref into `pinia.state.value.cart`, the single tree that devtools inspects, that server rendering serialises, that `$state` exposes and that `$subscribe` watches. A ref that is not returned never enters that tree. It still works inside the store's own actions and getters, which is exactly why the bug hides. ## What an unreturned couponCode loses - **SSR hydration.** The server serialises `pinia.state.value`, and `couponCode` is not in it. The client runs the setup function again, so the ref starts from its initial value and can disagree with the server-rendered HTML. - **Devtools.** The state inspector shows the state tree, so the coupon is invisible while you debug a pricing bug. - **Plugins.** A plugin that saves or restores `store.$state`, such as a persistence plugin, never sees it. - **State APIs.** `$patch`, assigning `$state` and `$subscribe` all go through the state tree, so none of them change or notice the coupon. - **Tests.** Test helpers that seed a store's initial state cannot preset it. The Pinia docs state it plainly: a setup store **must return all state properties**, you cannot have private state, and not returning state or making it readonly breaks SSR, devtools and other plugins. ## Readonly is not a way out Returning `readonly(couponCode)` looks like a compromise, but Pinia has to **write** state: hydration assigns each serialised value into its state ref, and `$patch` writes through the tree. A readonly wrapper refuses those writes, which is why the docs group readonly state with unreturned state. ## Returning too much is a bug as well The opposite mistake is returning values that are not the store's own state. The docs warn against returning the route from `useRoute()` or a value obtained with `inject()`: the route is a reactive object, so Pinia would file it under the cart's state, while components can simply call `useRoute()` themselves. Plain constants are harmless but pointless as store members; a tax rate can stay a module-level constant. Stateful objects a store must return without hydrating them are marked with `skipHydrate()`, which belongs to server rendering. There is one documented case of leaving a ref out on purpose: the Pinia composables cookbook keeps a `ref<HTMLVideoElement>()` unreturned because a DOM element is client-only and cannot be serialised, so it should never be state. That is the exception that proves the rule: the ref is omitted *because* it must not be state, not to hide data that is. ## What to do instead 1. **Return every ref and reactive object** that holds data, `couponCode` included. Make 'write only through actions' a code-review convention rather than a runtime barrier. 2. **Move truly internal state into a second store**, for example `defineStore('cart-internal', …)` in a module that components never import, and call it from the cart store's setup. Its state is returned by its own store, so it is still serialised, inspectable and patchable. 3. **Keep non-state values out of the store**: constants in modules, router and injected values read where they are needed. ```ts export const useCartStore = defineStore('cart', () => { const items = ref<{ price: number; qty: number }[]>([]) const couponCode = ref<string | null>(null) const TAX_RATE = 0.2 // not state: stays out of the return object const total = computed( () => items.value.reduce((s, i) => s + i.price * i.qty, 0) * (1 + TAX_RATE), ) function applyCoupon(code: string) { couponCode.value = code } return { items, couponCode, total, applyCoupon } }) ``` ## What interviewers listen for Senior candidates connect the rule to its reason: Pinia's state is whatever it finds among the refs and reactive objects in the returned object, and hydration, devtools, plugins and patching are all built on that one tree. Strong answers: - state the classification rule for returned values precisely; - name at least two consumers of the state tree that silently miss an unreturned ref; - reject `readonly()` as a workaround and explain why; - separate data that must not be state, such as a DOM element, from data someone merely wants hidden.

  • Would returning readonly(couponCode) keep it private but still count as state?
    No. The docs list readonly state alongside unreturned state as breaking SSR, devtools and plugins. Pinia has to write state: hydration assigns each serialised value into its state ref, and `$patch` writes through the state tree, and a readonly wrapper refuses both. Return the writable ref and enforce 'change it only through actions' by convention and review.
  • How do you keep a value internal without breaking the store?
    Put it in a second store, say `defineStore('cart-internal', …)`, in a module components never import, return its state normally, and call it inside the cart store's setup. Its state still lives in `pinia.state`, so SSR, devtools and plugins keep working, while components only use `useCartStore`. Values that are not state at all, like a tax rate, can stay plain module constants.

saying these in an interview costs you the question

  • Any ref declared in a setup store becomes state, returned or not.
  • Leaving a ref out of the return object is the supported way to get private state.
  • Returning readonly(ref) gives private state that still serialises.
  • Returning useRoute() from the store is harmless; it just adds more state.
  • A returned plain constant becomes reactive state too.