skip to content

In a Pinia setup store for checkout, what happens to a composable's watchers, lifecycle hooks and inject() calls, and what does the store expose from it?

level: seniorimportance: nice to knowfreq 24%

answer

  1. setup runs in the store's scope
  2. lives until $dispose
  3. lifecycle hooks do not follow the store
  4. app-level inject only
  5. refs state, functions actions

basics

~20 s

A composable's watchers and computeds join the store's effect scope and live until the store is disposed. Component lifecycle hooks do not follow the store, and inject() sees only app-level provides. Returned refs become state, functions actions, computeds getters.

solid answer

~50 s

Pinia runs a setup store's function inside the store's own effect scope and inside the app's `runWithContext`. So a composable's `watch`, `computed` and `onScopeDispose` cleanups belong to the store: they live as long as the store, normally the whole app, and stop on `$dispose()`. Component lifecycle hooks such as `onMounted` and `onUnmounted` do not follow the store: created outside a component, Vue warns there is no active component instance and the hook never runs; created during a component's setup, the hook attaches to that one component, so its cleanup runs when that component unmounts while the store lives on. `inject()` resolves app-level provides, which is why `useRouter()` works, but not a component's `provide`. Finally, whatever the store returns is classified: refs are state (serialised, so client-only refs stay unreturned or are marked for the server), functions are actions, computeds are getters.

code

ts · 21 lines
ts
import { defineStore } from 'pinia'
import { onScopeDispose, ref } from 'vue'
import { useRouter } from 'vue-router'

function useCountdown(seconds: number) {
  const left = ref(seconds)
  const id = setInterval(() => { if (left.value > 0) left.value-- }, 1000)
  onScopeDispose(() => clearInterval(id)) // follows the store, not a component
  return left
}

export const useCheckoutStore = defineStore('checkout', () => {
  const router = useRouter()        // app-level inject: works
  const reservationLeft = useCountdown(600) // state: a ref

  async function finish(orderId: string) {
    await router.push(`/orders/${orderId}`)
  }

  return { reservationLeft, finish }
})

go deeper

for a junior

Know that a setup store can call composables, and that returned refs become state and returned functions become actions.

for a middle

Explain that a store's effects live in the store's scope, and that cleanup belongs in onScopeDispose rather than onUnmounted.

for a senior

Audit composables used in stores for lifecycle hooks, component-level inject and non-serialisable returned state.

for a principal

Decide which stateful logic belongs in long-lived stores versus component composables, given the lifetime each implies.

## Where a setup store's code runs When a setup store is created, Pinia runs its setup function in two contexts at once: - an **effect scope** that belongs to the store, created inside the pinia's own root scope; - the **app's context**, through the app's `runWithContext()`, when the pinia is installed with `app.use(pinia)`. Everything a composable does during setup inherits those two facts. ## Watchers, computeds and cleanups A composable called in setup, say a `useCountdown()` that starts a reservation timer for the checkout, creates `watch`es, `computed`s and perhaps an interval it clears in `onScopeDispose`. All of that is collected by the **store's** scope: 1. It keeps running as long as the store exists, which in a normal app is until the app is torn down; stores outlive the components that use them. 2. It stops when `store.$dispose()` stops the store's scope; the composable's `onScopeDispose` cleanups run then. 3. It does **not** stop when the component that first used the store unmounts. That is usually what you want for shared state, and a surprise for composables written for a component's lifetime. ## Component lifecycle hooks `onMounted`, `onUnmounted` and the other lifecycle hooks attach to the **current component instance**, and a store is not a component: | Where the store is first created | What `onUnmounted(cleanup)` in its setup does | |---|---| | outside any component (start-up, a router guard) | Vue warns that there is no active component instance; `cleanup` never runs | | during some component's setup | attaches to that component; `cleanup` runs when it unmounts, while the store lives on | Neither is correct for a store. Composables meant for stores should clean up with `onScopeDispose`, which follows the store's scope. ## inject() inside a store Because setup runs inside `runWithContext()`, `inject()` sees what was provided **on the app**, with `app.provide` or by a plugin. Vue Router installs its router that way, so `useRouter()` works inside a setup store's function, for example to navigate after checkout. What it cannot see is a value provided by a **component** with `provide()`: the store is not part of the component tree. A pinia created without an app, as in some unit tests, has no app context at all. ## What the store exposes The setup store classifies each returned value, and composables return all three kinds: - **refs and reactive objects** become **state**: shown in devtools, serialised for server rendering and hydrated on the client; - **functions** become **actions**, visible to action hooks; - **computeds** become **getters**. Two consequences. First, return everything that is really state, because unreturned state is invisible to devtools and plugins. Second, a ref holding something that must not be serialised, such as a DOM element, stays unreturned, as Pinia's composables cookbook shows, and client-only refs that must not take the server's value are marked with Pinia's `skipHydrate()`. ## Option stores Option stores have no setup function. The cookbook allows composables only inside `state()`, and only when they return **writable** refs; a composable that returns functions or read-only data needs a setup store. ## Checklist - Store-bound composables clean up with `onScopeDispose`, never `onUnmounted`. - `inject()` in a store relies on app-level provides only. - Returned refs are real, serialisable state; anything else stays unreturned or is marked. - Long-lived effects in a store are intended, not leaked. ## Debugging a store composable that misbehaves Three symptoms point at the rules above: 1. **An interval or listener keeps running after every page that used it is closed.** It lives in the store's scope, which is intended; if it should stop earlier, the logic belongs in a component composable, or the store needs an action that stops it. 2. **A cleanup runs too early, when one particular page unmounts.** The composable used `onUnmounted` and the store was first created on that page, so the hook attached there. Switch to `onScopeDispose`. 3. **`inject()` returns `undefined` in a store but works in components.** The value is provided by a component, not by the app. Provide it on the app, or pass it into an action as an argument. Each fix follows from one idea: a setup store runs like a small application-level component with its own scope and no place in the component tree.

  • When does a composable's onScopeDispose cleanup run inside a Pinia setup store?
    When the store's effect scope stops: on `store.$dispose()`, or when the pinia's root scope is stopped as the app is torn down. It does not run when components using the store unmount, because the store's scope is not a child of any component's scope.
  • Why can a component's provide() value not be injected inside a Pinia setup store?
    The store's setup runs inside the app's context, not inside any component, so `inject()` can only see values provided on the app. A store is shared by every component, so there is no single component whose `provide()` it could sensibly read; pass such values into an action instead.

saying these in an interview costs you the question

  • Effects started in a setup store stop when the first component using it unmounts.
  • onUnmounted in a store composable runs when the store is disposed.
  • inject() inside a setup store can read any component's provide().
  • Returning a DOM element ref from a setup store is harmless.
  • Option stores can use any composable anywhere in their options.