skip to content

Shallow & Raw State

shallowRef, shallowReactive and markRaw opt large or foreign objects out of deep proxying, while toRaw and readonly expose or guard state. Interviewers ask when deep reactivity costs too much.

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

explore

questions

4

In Vue 3, what does wrapping state in `readonly()` do, and does the readonly view still update when the original changes?

level: juniorimportance: should knowfreq 42%

answer

  1. a proxy, not a copy
  2. writes refused with a dev warning
  3. deep by default
  4. reads pass through to the original

basics

~20 s

readonly() 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 lines
ts
import { 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-render

go deeper

for a junior

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.

for a middle

Explain that it is deep and lazy, tracks only when the target is reactive, and differs from shallowReadonly and Object.freeze.

for a senior

Use readonly views to enforce a single writer in composables and provided state, and know the plain-object case that silently stops updating.

for a principal

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.
open as a page

In Vue 3, what does `shallowRef()` track, and why does `list.value.push(item)` on one not update the view without `triggerRef()`?

level: middleimportance: should knowfreq 48%

basics

~20 s

shallowRef tracks only reads and writes of .value itself; the inner value is stored as-is, not proxied, so mutating inside it runs no trap and triggers nothing. Assign a new value to .value, or call triggerRef(ref) after the mutation, to notify dependents.

open as a page

A Vue 3 component keeps a charting library's chart instance in `ref()`, and calls on it start throwing or slowing down - why, and how should it be held?

level: seniorimportance: should knowfreq 38%

basics

~20 s

ref() turns the instance into a deep reactive proxy, so library methods run with the proxy as this: private fields throw, identity checks fail, and internal reads and writes pay for tracking. Keep it in a plain variable, a shallowRef, or markRaw it.

open as a page

In Vue 3, what is the difference between `markRaw()` and `toRaw()`, and when is each the right tool?

level: middleimportance: nice to knowfreq 30%

basics

~20 s

markRaw(obj) flags an object so Vue never wraps it in a proxy, even inside reactive state. toRaw(proxy) returns the original object behind an existing proxy, for short untracked reads or writes. markRaw prevents proxying up front; toRaw steps around a proxy after the fact.

open as a page