In a Vue 3 module-level store, how do you stop components mutating state directly, and what does readonly() actually enforce?
answer
- keep the writable object private
- export a view plus functions
- deep, but a proxy only
- warning, not an exception
basics
~20 sKeep 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.
solid answer
~40 sKeep the writable `reactive()` object unexported and export two things: `readonly(state)` for reading, and named action functions (`notify`, `dismiss`, `clearAll`) that are the only code writing to the private object. Components read the view and call the actions, so every mutation goes through a function you can search for, validate and log. `readonly()` returns a **deep** proxy over the same object: reads stay reactive, nested objects are readonly too, and a write such as `state.items.push(...)` is refused; in development Vue warns `Set operation on key "..." failed: target is readonly.`, and in production the write is silently dropped rather than thrown. It is a guard rail for your own code, not protection: the original object is still mutable inside the module, and anything that gets a reference to it can bypass the view.
code
vue · 14 lines<script setup lang="ts">
import { notifications, dismiss } from './notifications'
function close(id: number) {
dismiss(id) // the action writes the private state
// notifications.items.splice(0, 1) would be refused: the view is readonly
}
</script>
<template>
<div v-for="n in notifications.items" :key="n.id">
{{ n.text }} <button @click="close(n.id)">x</button>
</div>
</template>go deeper
Know the shape: private reactive state, an exported readonly() view and exported functions that change the state.
Explain that readonly() is a deep, reactive proxy that refuses writes with a development warning and silently ignores them in production.
Use the pattern to make every mutation searchable and validated, and be honest that readonly is a guard rail backed by types and review, not a security boundary.
Decide whether hand-rolled readonly-plus-actions stores are the team standard or a stepping stone, and keep their shape compatible with a later store library.
## Why mutation control matters A module-level store that exports its `reactive()` object lets **any** component change **any** field. With a notification queue that means a toast component might splice the array, a form might overwrite `items` with a filtered copy, and a badge might flip `read` flags. When a bug appears, the list of places that could have caused it is every importer. The Vue docs recommend centralising mutation logic next to the state, in functions whose names express intent. ## The pattern: private state, readonly view, actions ```ts // notifications.ts import { reactive, readonly } from 'vue' interface Note { id: number; text: string; read: boolean } const state = reactive({ items: [] as Note[] }) let nextId = 1 export const notifications = readonly(state) export function notify(text: string) { state.items.push({ id: nextId++, text, read: false }) } export function dismiss(id: number) { const i = state.items.findIndex((n) => n.id === id) if (i !== -1) state.items.splice(i, 1) } ``` Three parts: 1. **Private state.** `state` is not exported, so no component can reach it by import. 2. **Readonly view.** `notifications` reads the same data and stays reactive: a component rendering `notifications.items` updates when `notify()` pushes. 3. **Actions.** `notify` and `dismiss` are the only writers. Validation, deduplication, a maximum queue length or logging go in one place. ## What readonly() enforces | Aspect | Behaviour | |---|---| | Depth | deep: nested objects and arrays read through it are readonly too | | Reactivity | reads through the view are tracked like reads of the original | | A write through the view | refused; the original is not changed | | Development build | logs `Set operation on key "..." failed: target is readonly.` | | Production build | the write is ignored silently, nothing is thrown | | The original object | still fully mutable by code that holds it | `readonly()` accepts a reactive object, a plain object or a ref. If you only need the top level protected, `shallowReadonly()` avoids the deep conversion. ## What readonly() does not do It is easy to overstate. Keep these limits in mind: - **It is not a security boundary.** It is a development-time guard against accidental writes; any code that holds the original object can still change it. - **It does not freeze.** The data changes all the time, through the actions; the view simply reflects those changes. - **It does not throw.** A component that tries to write gets a console warning in development and nothing in production, so the bug can go unnoticed if nobody reads the console. Type-level `readonly` from TypeScript (`DeepReadonly` in Vue's typings) catches most of these writes at compile time. - **It does not validate actions.** Whether `notify('')` is allowed is still your function's job. ## Why actions beat free-form writes Routing every change through a named function pays off quickly: - **One place for rules.** A maximum of five visible toasts, de-duplicating identical messages or auto-dismissing after a timeout lives in `notify()`, not in every caller. - **Searchable history.** Finding every caller of `dismiss` is a single search; finding every `items.splice` across a codebase is not. - **Easier refactoring.** The internal shape of `state` can change without touching components, because they only depend on the view and the function names. ## Alternatives inside the same pattern - **Methods on the store object.** The Vue docs' example defines `increment()` on the reactive object itself and calls `store.increment()`. This centralises logic but still exports a writable object, so it guides rather than enforces. - **A singleton composable** such as `useNotifications()` that returns `{ items: readonly(state).items, notify, dismiss }`. Same idea with a composable-shaped API. - **Separate getter functions** for derived data, such as `unreadCount` as a module-level `computed`. ## When this is enough Private state plus a readonly view plus actions is the core of what store libraries formalise. For a small app, or one slice of shared state, it is often all you need. The remaining gaps (devtools inspection, hot reload that keeps state, SSR isolation, plugins) are the reasons teams later adopt a library.
- Is data exposed through Vue's readonly() still reactive for the components that read it?Yes. `readonly()` returns a proxy over the same original object, and reads through it are tracked. When an action mutates the original, components rendering the readonly view update as usual.
- A teammate says readonly() makes the store tamper-proof. What would you answer?It only blocks writes made through the view, and only with a development warning; production drops them silently. The original object remains mutable to any code holding it. Treat it as a guard rail against mistakes, and back it with TypeScript's readonly types and code review.
saying these in an interview costs you the question
- readonly() makes a deep copy that no longer updates.
- Writing through a readonly() proxy throws an exception in production.
- readonly() protects only the top-level properties of the store.
- readonly() makes the store secure against any mutation.
- Exporting the reactive object plus some helper functions fully enforces actions.