In Vue 3, what does wrapping state in `readonly()` do, and does the readonly view still update when the original changes?
answer
- a proxy, not a copy
- writes refused with a dev warning
- deep by default
- reads pass through to the original
basics
~20 sreadonly() returns a deep read-only proxy over the original object or ref: writes are refused with a development warning, and nested objects read through it are read-only too. Wrapped around reactive state it still tracks, so it reflects every change to the original.
solid answer
~40 s`readonly(target)` returns a **proxy**, not a copy. Its `set` and `deleteProperty` traps refuse the operation and, in development, warn that the target is readonly; they do not throw. It is **deep**: nested objects are wrapped as readonly when you read them, and refs inside are unwrapped and made readonly too. When the target is `reactive()` state or a ref, reads go through to it and are tracked, so a component reading the readonly view re-renders when the owner mutates the original. That is the usual pattern: a composable or a `provide()` call exposes `readonly(state)` and keeps the mutating functions to itself. `shallowReadonly()` guards only the root-level properties.
code
ts · 19 linesimport { reactive, readonly } from 'vue'
export function useCounter() {
const state = reactive({ count: 0 })
function increment() {
state.count++
}
return {
state: readonly(state), // consumers read reactively...
increment, // ...but must change it through here
}
}
// in a component:
// const { state, increment } = useCounter()
// state.count++ -> dev warning, value unchanged
// increment() -> state.count is 1, readers re-rendergo deeper
Recall that readonly() gives a read-only proxy of state, writes through it only warn in development, and it still updates when the original changes.
Explain that it is deep and lazy, tracks only when the target is reactive, and differs from shallowReadonly and Object.freeze.
Use readonly views to enforce a single writer in composables and provided state, and know the plain-object case that silently stops updating.
Decide where ownership of state is enforced - readonly views, store actions or conventions - and how strict to make it across teams.
## What `readonly()` returns `readonly(target)` accepts a plain object, a `reactive()` object or a ref, and returns a **readonly proxy** over it. Nothing is copied and nothing is frozen: the original stays fully mutable for whoever holds it. The proxy only changes what happens when code writes **through the proxy**. - **Writes and deletes** go to traps that refuse the operation. In a development build Vue logs a warning such as `Set operation on key "count" failed: target is readonly.`; in production the write is ignored silently. The trap reports success, so even strict-mode code does not throw. - **Nested objects** read through the proxy come back wrapped as readonly too, lazily, on access. `view.user.name = 'x'` is refused just like `view.count = 1`. - **Refs inside** are unwrapped the same way `reactive()` unwraps them, and the unwrapped values are made readonly as well. ## Does the view stay live? It depends on what you wrapped: | Wrapped target | Reads tracked? | Sees owner's changes? | |---|---|---| | `reactive()` object | yes, the read passes through the reactive proxy | yes, effects re-run | | a `ref` | yes, `.value` goes through the ref's getter | yes | | a plain object | no, a readonly proxy over plain data does not track | only values read later; nothing re-runs | The first two rows are the normal case, and the docs' own example shows it: a `watchEffect` reading `copy.count` re-runs when `original.count++` runs, while `copy.count++` only produces a warning. ## Why you would use it 1. **Owning mutations.** A composable returns `readonly(state)` plus functions like `increment()`. Every change goes through code the composable controls, which keeps state changes traceable. 2. **`provide()` / `inject()`.** The docs recommend providing `readonly(count)` when an injecting component should read a value but never change it. 3. **Guarding configuration.** A readonly view of shared settings turns accidental writes into loud development warnings instead of silent corruption. ## `readonly()` vs `shallowReadonly()` vs `Object.freeze()` | | `readonly()` | `shallowReadonly()` | `Object.freeze()` | |---|---|---|---| | Mechanism | proxy | proxy | mutates the object itself | | Depth | deep | root-level properties only | root-level properties only | | Original still mutable by its owner | yes | yes | no | | Ref unwrapping | yes | no | not applicable | | Failed write | dev warning, ignored | dev warning at root; nested writes succeed | ignored, or TypeError in strict mode | `shallowReadonly()` is what Vue itself hands to `setup()` as `props` in development builds, so writing `props.title = 'x'` warns. Nested objects inside a prop are not protected by it, which is why mutating `props.user.name` succeeds (and is still a bad idea). A frozen object has a side effect worth knowing: Vue never wraps a non-extensible object in a reactive proxy, so passing a frozen object to `reactive()` gives you the object back, non-reactive. ## Checking what you hold Three utilities tell you what kind of object you have, which helps when a value arrives from a composable or an `inject()`: - `isReadonly(view)` is `true` for any readonly proxy, deep or shallow. - `isReactive(view)` is `true` for a readonly proxy **over reactive state** - the check looks through the readonly layer to the object beneath - and `false` for a readonly proxy over a plain object. That second case is exactly the one that will not update. - `isProxy(view)` is `true` for any proxy created by `reactive()`, `readonly()`, `shallowReactive()` or `shallowReadonly()`. In practice, `isReactive(readonly(x))` answers the interview follow-up "will this view stay live?" in one line. ## Common mistakes - Treating `readonly()` as a snapshot. It is a live view of reactive state, not a copy. - Expecting it to throw. Writes are refused and warned about in development, and silently ignored in production. - Expecting it to protect the original. Anyone holding the original object can still mutate it. - Wrapping a plain object and expecting components to update when it changes; there is nothing to track.
- In Vue 3, does `readonly()` over a plain, non-reactive object update components when that object is mutated?No. A readonly proxy over plain data does not call `track()` on reads, and nothing calls `trigger()` when the plain object changes, so no effect re-runs. Later reads return the new values, but only if something else causes a render. Wrap `reactive()` state or a ref when readers must update.
- Why expose `readonly(state)` from a Vue composable rather than the reactive object itself?It keeps the composable the only writer. Consumers still read reactively, but every change has to go through the functions it returns, so validation, logging and invariants live in one place and a stray assignment in a component becomes a development warning instead of a silent change.
saying these in an interview costs you the question
- readonly() makes a frozen copy, so it stops reflecting later changes.
- Writing to a readonly proxy throws an error in every build.
- readonly() only protects top-level properties; nested objects stay writable.
- Once state is wrapped in readonly(), its owner can no longer change it either.
- readonly() and Object.freeze() are two names for the same behaviour.