skip to content

Store-Free Shared State

A module-scoped reactive object or a singleton composable can act as a store with no library, until SSR shares it across requests. Interviewers ask when to adopt a store library instead.

part ofVue.jsoverview, primer and where to startread it →
on this pageshow

explore

questions

5

In Vue 3, how can distant components share a notification queue without any store library?

level: juniorimportance: should knowfreq 55%

answer

  1. reactivity lives outside components
  2. one module, imported twice
  3. module scope means one instance
  4. reactive() or ref() in store.ts

basics

~20 s

Create the queue with reactive() or ref() at the top level of a plain module and import it where needed. Module scope makes it a singleton, and Vue tracks reactive reads anywhere, so every component reading it updates.

solid answer

~40 s

Vue's reactivity system is not tied to components, so a `reactive()` object or a `ref()` created at the top level of an ordinary `.ts` module is a working store. Because a module is evaluated once, every component that imports it gets the **same** object: the header badge that shows the count and the toast list in the layout read the same array, and a form deep in a page can push to it. Any component whose render or computed reads the queue re-renders when it changes, with no props, events or library in between. The Vue docs present this as the simplest form of state management. Its limits are what come next: any importer can mutate it, and under server-side rendering the singleton is shared between requests.

code

ts · 10 lines
ts
// notifications.ts
import { reactive, computed } from 'vue'

export const notifications = reactive({
  items: [] as { id: number; text: string; read: boolean }[]
})

export const unreadCount = computed(
  () => notifications.items.filter((n) => !n.read).length
)

go deeper

for a junior

Recall that reactive() or ref() created at the top of a module and imported into several components gives them one shared, reactive value.

for a middle

Explain why it works: modules are evaluated once and Vue tracks reactive reads wherever the object was created, and name the pattern's limits.

for a senior

Recognise when this pattern is enough and when uncontrolled mutation, SSR or tooling needs push the team toward readonly views, per-request state or a store library.

for a principal

Decide how far a codebase can go on module stores before conventions and tooling matter more than zero dependencies, and plan a migration path that keeps the state-plus-actions shape.

## The problem it solves Some state is needed by components that are far apart in the tree. A **notification queue** is a typical case: - a form deep inside a page adds "Saved" or "Upload failed"; - a toast list mounted in the layout renders the messages; - a badge in the header shows how many are unread. Passing the queue down as props and bubbling additions up as events through every intermediate component is tedious and couples components that do not care about notifications. ## Why a plain module works in Vue 3 Vue's reactivity is **decoupled from the component model**. `reactive()`, `ref()` and `computed()` can be called anywhere, not only inside `setup()`. Two facts combine: 1. An ES module is evaluated **once**; every `import` receives the same exported binding. 2. A component that reads a reactive value during render (or in a `computed` it renders) tracks it, wherever that value was created. So a reactive object created at module scope is a **singleton** shared by every importer, and every reader updates when it changes. ```ts // notifications.ts import { reactive } from 'vue' export const notifications = reactive({ items: [] as { id: number; text: string }[] }) ``` ```vue <!-- ToastList.vue --> <script setup lang="ts"> import { notifications } from './notifications' </script> <template> <ul> <li v-for="n in notifications.items" :key="n.id">{{ n.text }}</li> </ul> </template> ``` Any other component can import `notifications` and push to `notifications.items`; the toast list and the header badge both update. ## reactive() or ref() for the store | Choice | Access | Notes | |---|---|---| | `reactive({ items: [] })` | `store.items` | groups several fields; do not reassign the whole object | | `ref([])` | `items.value` in script | fine for a single value; can be replaced wholesale | | `computed()` at module scope | `unread.value` | derived store value shared by all importers | Both are fine. The Vue docs use a single `reactive()` object for the store example and note that `ref()` and `computed()` can be shared the same way. ## What you get, and what you do not What the pattern gives you: - a single source of truth with no library and no setup; - fine-grained updates: only components that read the queue re-render; - plain TypeScript types, since the store is just an object. What it does **not** give you by itself: - **Controlled mutation.** Every importer can write anything. The Vue docs recommend putting intention-revealing actions next to the state; exporting a `readonly()` view makes the rule enforceable. - **Isolation between requests under SSR.** On a server, modules are initialised once per process, so the singleton is shared by all users. This is called cross-request state pollution. - **Tooling.** Devtools inspection of the store, hot-module replacement that keeps its state, and plugin hooks are what store libraries add. ## Where it fits The pattern is a good fit for client-only apps and for small slices of shared state: a notification queue, a theme preference, a feature flag cache. It is also a good first step: if the app later adopts a store library, the state and action functions move over with little change, because the shape (state plus actions) is the same. ## Testing a module store Module state lives as long as the module does, which affects tests: - Tests that run in the same module registry share the store, so a notification pushed in one test is still there in the next. - Export a `reset()` action (or a factory used by the module) so each test can start clean. - Prefer asserting through the store's actions and the rendered output rather than poking at internal fields. ## Common mistakes - Calling a method defined on the store object without its receiver (passing `store.dismiss` as a bare callback), so `this` is lost; the docs call `store.increment()` with the parentheses for this reason. - Letting every component write to the store directly, so when a bad value appears nobody can tell which importer wrote it. - Creating the reactive object **inside** a function that each component calls, which gives each caller its own copy and nothing is shared.

  • Why does moving reactive({ items: [] }) inside a function that each component calls break sharing?
    Each call creates a new reactive object, so every component gets its own queue. Sharing depends on the object being created once, at module scope, where every importer receives the same instance.
  • Does every component re-render when a notification is pushed to a module-level store?
    No. Only components whose render (or the computeds they render) read the queue are tracked as dependents. A component that imports the module but never reads `items` during render does not update.

A module-level store is a single noticeboard in a shared hallway: anyone can pin a note and everyone walking past sees it, but anyone can also rip notes down.

saying these in an interview costs you the question

  • Vue reactivity only works inside components, so shared state needs a library.
  • Each component that imports the module gets its own copy of the store.
  • Every component in the app re-renders when the shared store changes.
  • A module-level reactive store is safe as-is under server-side rendering.
  • Shared state must always be passed down as props from the root.
open as a page

In a Vue 3 module-level store, how do you stop components mutating state directly, and what does readonly() actually enforce?

level: middleimportance: should knowfreq 42%

basics

~20 s

Keep the reactive state private to the module, export readonly(state) plus action functions such as push and dismiss. readonly() gives a deep proxy whose writes are ignored with a development-only warning; it guides callers but is not a hard security boundary.

open as a page

In Vue 3, what changes when a useNotifications() composable creates its ref at module scope instead of inside the function?

level: middleimportance: should knowfreq 45%

basics

~20 s

A 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.

open as a page

In a Vue 3 SSR app, why does a module-level reactive() notification store leak one user's messages to another, and how must it change?

level: seniorimportance: should knowfreq 45%

basics

~20 s

On a server, modules are initialised once at boot and reused by every request, so a module-level reactive() store is shared by all users. Vue calls this cross-request state pollution; create the store per request in the app factory and provide it.

open as a page

When should a Vue 3 team stop hand-rolling module-level reactive stores and adopt a store library?

level: principalimportance: should knowfreq 38%

basics

~20 s

Adopt a store library when shared state needs things the hand-rolled pattern lacks: team conventions, devtools inspection and time travel, hot reload that keeps state, and per-request SSR isolation. Until then, module stores with readonly views and actions are enough.

open as a page