In Vue 3, what changes when a useNotifications() composable creates its ref at module scope instead of inside the function?
answer
- where the ref() line sits
- per call versus once
- global and local side by side
- who owns a lazily created watcher
basics
~20 sA ref created inside the composable is new on every call, so each component gets private state; a ref created at module scope is created once and shared by every caller, turning the composable into a singleton store.
solid answer
~40 sA composable is just a function, so the position of `ref()` decides the sharing. Inside `useNotifications()`, each call creates a fresh ref: every component has its own queue. At module scope, above the function, the ref is created once when the module loads and the function returns that same ref to every caller, which makes the composable a **singleton store** with a composable-shaped API. The two can coexist: the Vue docs show a module-level `globalCount` and a per-call `localCount` returned from one composable. One trap with singletons: if the shared state or a watcher is created lazily on the first call, inside a component's `setup`, that watcher belongs to that component and stops when it unmounts, so shared effects should be created at module scope.
code
ts · 14 linesimport { ref } from 'vue'
const shared = ref(0) // one instance for every caller
export function useCounter() {
const local = ref(0) // a new instance per call
return { shared, local }
}
const a = useCounter()
const b = useCounter()
a.shared.value++
a.local.value++
console.log(b.shared.value, b.local.value) // 1 0go deeper
Know that a ref declared inside a composable is per call, while one declared above it at module scope is shared by every caller.
Show both placements in one composable and explain why a singleton composable is still just a module-scoped store with a function in front.
Spot lazily created watchers in singleton composables that die with their first caller, and move shared effects to module scope.
Set conventions for which composables may hold shared state, how that is documented, and how they migrate to per-request state or a store library.
## A composable is a function In Vue 3, a **composable** is a function (by convention named `useX`) that uses the Composition API and returns reactive state and functions. Nothing about the name makes it shared or private. What decides that is ordinary JavaScript scope: **where the `ref()` or `reactive()` call runs**. ## Per-call state versus a singleton ```ts import { ref, readonly } from 'vue' // Singleton: created once, when the module is first imported const items = ref<string[]>([]) export function useNotifications() { // Per call: a new ref for every component that calls the composable const draft = ref('') function notify(text: string) { items.value.push(text) } return { items: readonly(items), draft, notify } } ``` | Where the state is created | How many instances | Who sees a change | |---|---|---| | inside `useNotifications()` | one per call | only the calling component | | module scope, above the function | one per page (per module instance) | every caller | | both, as above | shared `items`, private `draft` | `items` everywhere, `draft` locally | The Vue docs' state-management guide shows exactly this split: a module-scope `globalCount` next to a per-call `localCount`, both returned from `useCount()`. ## Why teams write singleton composables A module-level store works without a function, so why wrap it? - **A consistent API.** Components already call `useRoute()`-style functions; `useNotifications()` looks the same whether it is local or shared. - **Encapsulation.** The raw ref can stay private while the function returns a readonly view plus actions. - **Room to change.** If the state later needs to be per-request (for SSR) or moved into a store library, callers keep calling `useNotifications()` and only its body changes. - **Mixing shared and local.** One call can return shared state and per-component helpers together. ## Singleton composable versus a plain export | | exported module-level `reactive()` | singleton `useNotifications()` | |---|---|---| | Instances | one per module instance | one per module instance | | Call-site syntax | `import { store }` | `const { items, notify } = useNotifications()` | | Can hide the writable state | only with a separate readonly export | naturally, by what it returns | | Can mix in per-component state | no | yes | | SSR risk | shared across requests | shared across requests | The runtime behaviour is the same; the difference is API design and how easily the implementation can change later. ## The lazy-initialisation trap A common variant creates the shared state on first use: ```ts import { ref, watch, type Ref } from 'vue' let items: Ref<string[]> | undefined export function useNotifications() { if (!items) { items = ref<string[]>([]) // Created during the first caller's setup watch(items, (v) => localStorage.setItem('notes', JSON.stringify(v)), { deep: true }) } return { items } } ``` The state is shared correctly, but the **watcher** is not neutral. Vue binds a watcher created synchronously during a component's `setup()` to that component, and stops it automatically when that component unmounts. So: 1. The first component to call `useNotifications()` (say, a page) owns the watcher. 2. The user navigates away and that page unmounts; the watcher stops. 3. Other components keep using the shared `items`, but nothing is persisted any more. The fix is to create shared effects **outside** any component, at module scope, so no component owns them, or to manage their lifetime explicitly with the APIs designed for that (effect scopes are their own topic). ## Checklist - Put shared state at module scope, private state inside the function. - Return a `readonly()` view of shared state plus action functions, not the writable ref. - Create watchers and other effects that serve the shared state at module scope, not lazily inside the first caller. - Remember that module scope means one instance per server process under SSR, so a singleton composable has the same cross-request risk as any module store. - Name it honestly: a reader should be able to tell from the docs comment that `useNotifications()` returns shared state.
- A singleton composable lazily creates a watch() on its first call. Why does persistence stop after some navigation?The watcher was created synchronously inside the first caller's setup, so Vue bound it to that component and stopped it when that component unmounted. The shared ref lives on, but its effect died with the component. Create shared effects at module scope instead.
- Is a singleton composable safer than exporting a module-level ref directly?Only in API shape. Both hold one module-scoped instance, so both are shared across requests under SSR. The composable's advantage is encapsulation: it can return a readonly view and actions, and its body can later change without touching callers.
saying these in an interview costs you the question
- Every composable call always gets its own fresh state, wherever the ref is declared.
- Naming a function useX makes Vue share its state between components.
- A watcher created in a singleton composable lives for the whole app regardless of who called it first.
- A singleton composable avoids cross-request state sharing under SSR.
- Shared and per-component state cannot be returned from the same composable.