skip to content

An invoice list calls the Pinia getter invoicesByStatus(status) in every row and filter chip; why is nothing cached, and how do you make these lookups cheap?

level: seniorimportance: should knowfreq 42%

answer

  1. getters take no parameters
  2. return a function instead
  3. the function is cached, not results
  4. the caller's render tracks reads
  5. precompute an index in a getter

basics

~20 s

A Pinia getter cannot take parameters, so invoicesByStatus returns a function; the computed caches that function, not its results, and every call filters again. Precompute a grouped index in an ordinary getter and look up by key.

solid answer

~40 s

Getters are computed values, so they cannot take arguments; the documented workaround is `invoicesByStatus: (state) => (status) => state.invoices.filter((i) => i.status === status)`. The computed caches only the returned function, and because the getter body never reads `state.invoices` itself, that function never changes. Each call filters again inside whatever effect made it, usually a component render; that render tracks `invoices`, so updates still arrive, but nothing is cached. With 2,000 invoices and a call per chip and row, that is many full scans per render. Move the work into a real getter: `byStatus` groups the invoices into a `Map` once per change, and callers read `invoice.byStatus.get('overdue')`. The docs' variant computes the shared part in the getter body and returns a cheap lookup. Through `storeToRefs`, the function sits on `.value`.

code

ts · 15 lines
ts
import { defineStore } from 'pinia'

type InvoiceStatus = 'draft' | 'sent' | 'paid' | 'overdue'
interface Invoice { id: string; status: InvoiceStatus; amount: number }

export const useInvoiceStore = defineStore('invoice', {
  state: () => ({ invoices: [] as Invoice[] }),
  getters: {
    // not cached per call: filters on every call
    invoicesByStatus: (state) => (status: InvoiceStatus) =>
      state.invoices.filter((i) => i.status === status),
    // cached: grouped once per change, then looked up
    byStatus: (state) => Map.groupBy(state.invoices, (i) => i.status),
  },
})

go deeper

for a junior

Know that a Pinia getter cannot take arguments and that returning a function is the documented workaround.

for a middle

Explain that the computed caches the returned function, not its results, and that storeToRefs exposes the function on .value.

for a senior

Show why reactivity survives while caching does not, measure the per-render cost, and replace hot lookups with an index getter.

for a principal

Set a guideline for derived lookups in large stores: indexed getters for hot paths, function getters only for rare, cheap calls.

## Why a getter cannot take arguments A Pinia getter is a Vue `computed` owned by the store. A computed has exactly one value, recalculated when its dependencies change; there is nowhere to pass `status`. Pinia's docs give the workaround: **return a function from the getter**. ```ts getters: { invoicesByStatus: (state) => { return (status: InvoiceStatus) => state.invoices.filter((i) => i.status === status) }, }, ``` The docs then say it plainly: when you do this, **getters are not cached anymore**; they are simply functions you invoke. ## What is cached, and what is not | Part | Cached? | Why | |---|---|---| | The returned function | yes | the computed stores it like any value | | Each call's result | no | the call runs `filter` every time | | The getter body | effectively forever | it reads no state, so the computed has no dependency that could change | The last row surprises people. The outer arrow only creates a closure; `state.invoices` is read later, **inside** the returned function, when it is called. The computed tracked nothing, so it keeps handing out the same function. ## Why the list still updates Reactivity is not lost, only caching. When a template calls `invoice.invoicesByStatus('overdue')`, the `filter` runs **during that component's render**. The render effect records the read of `invoices` and of each invoice's `status`, so when an invoice is marked paid the component re-renders and calls the function again. The work simply lands in every caller, every render, instead of once in the store. ## The cost at scale Consider 2,000 invoices, a toolbar with five status chips showing counts, and a table that calls the lookup for grouping: - each chip filters all 2,000 invoices on every render of the toolbar; - each re-render triggered by any tracked change repeats all of it; - each call also returns a **new array**, so child components that receive it as a prop always see a changed value and update too. ## Making lookups cheap 1. **Precompute an index in an ordinary getter.** `byStatus: (state) => Map.groupBy(state.invoices, (i) => i.status)` builds the grouping once per change; `invoice.byStatus.get('overdue')` is then a cached lookup, and the arrays stay the same between changes. A `countsByStatus` getter serves the chips the same way. 2. **Cache the shared part inside the getter.** The docs' variant computes the expensive part in the getter body, so the computed tracks it, and returns a cheap function: `const open = state.invoices.filter(isOpen); return (id) => open.find((i) => i.id === id)`. 3. **Fix the argument where it is used.** A component that always needs `'overdue'` can wrap the read in its own `computed`, which caches for that component. 4. **Keep function getters for rare, cheap calls**, such as a lookup made once in an action or on a click. ## Reading one from script setup Through `storeToRefs(invoice)`, a function-returning getter becomes a ref whose value is the function: `const { invoicesByStatus } = storeToRefs(invoice)` and then `invoicesByStatus.value('draft')` in `<script setup>`; templates unwrap it automatically. Reading it straight from the store, `invoice.invoicesByStatus('draft')`, needs no `.value`. ## What to say in the interview The strong answer names both halves: the function getter keeps reactivity because the caller's effect tracks the reads, and it loses caching because the computed caches the function rather than the calls. Then it offers the index getter as the fix and states when the function form is still fine. ## Slips to avoid - Claiming the function getter "is not reactive": it is, through the caller. - Claiming Pinia memoises per argument: it does not; memoising per argument would need your own cache, which then has to be invalidated when the invoices change, and that is exactly what an index getter does for free. - Adding the index getter but reading it with a fresh `filter` anyway, for example `invoice.byStatus.get(s)?.filter(…)` inside a hot render loop. - Forgetting that `Map.groupBy` omits statuses with no invoices, so a lookup for an empty status returns `undefined`; default it with `?? []` at the call site or seed every status in the getter.

  • In the Pinia docs' cached variant, when does the getter body run again?
    When state it read in the body changes. In `const open = state.invoices.filter(isOpen); return (id) => open.find(…)`, the body reads `invoices` and each invoice's status, so the computed recomputes `open` and hands out a new function after such a change. Calls between changes reuse `open` and only pay for the `find`.
  • Why can passing the result of invoicesByStatus('paid') as a prop cause extra child updates?
    Each call runs `filter`, which returns a new array. On every parent render the child receives a different array reference, so Vue treats the prop as changed and updates the child even when the contents are identical. An index getter returns the same cached array until the invoices actually change.

saying these in an interview costs you the question

  • A getter that returns a function caches each call's result per argument.
  • Function-returning getters are not reactive, so the list never updates.
  • Pinia getters accept arguments directly, like invoice.total(taxRate).
  • The getter body of a function-returning getter re-runs on every call.
  • Wrapping the call in storeToRefs brings the caching back.