readonly, markRaw and toRaw
Hand out state nobody may change, keep an object out of the reactivity system, and recover the original object behind a proxy.
readonly(target) | shallowReadonly(target) | markRaw(obj) | toRaw(proxy)Reactive state is mutable by anyone who can reach it. These three functions draw the boundaries: who may write, what may be proxied, and how to get back to the plain object underneath.
readonly
readonly(target) returns a deeply read-only proxy of a reactive object or a plain object. Reads still track, so views built on it update normally; writes are refused, with a warning in the development build. It is the right return value for a composable or a store that exposes its state but keeps mutations behind named functions. shallowReadonly(target) protects root-level properties only. isReadonly(value) reports whether a value came from either, or from a computed with no setter.
markRaw
markRaw(obj) flags an object so Vue never wraps it in a proxy, and returns the same object. Class instances often need this. A proxy makes this the proxy inside methods, which throws on private fields such as #state, and foreign code that compares identities or holds internal references can break in quieter ways. Map instances, editor instances and observers belong in markRaw.
toRaw
toRaw(proxy) returns the original object behind a proxy from reactive, readonly, shallowReactive or shallowReadonly. Use it for a read-only pass over a large structure, or to hand plain data to a library. Writes through it bypass tracking entirely, so nothing updates.
Example
<script setup lang="ts">
import { reactive, readonly, markRaw } from 'vue'
class Formatter {
#prefix: string // a private field: broken when `this` is a proxy
constructor(prefix: string) {
this.#prefix = prefix
}
format(value: number): string {
return `${this.#prefix}${value.toFixed(2)}`
}
}
interface Settings {
theme: 'light' | 'dark'
amount: number
formatter: Formatter
}
const settings = reactive<Settings>({
theme: 'light',
amount: 12.5,
formatter: markRaw(new Formatter('$'))
})
// A view for the template and for children: reads track, writes are refused.
const publicSettings = readonly(settings)
function toggleTheme(): void {
settings.theme = settings.theme === 'light' ? 'dark' : 'light'
}
</script>
<template>
<p>Theme: {{ publicSettings.theme }}</p>
<p>Total: {{ settings.formatter.format(publicSettings.amount) }}</p>
<button @click="toggleTheme">Toggle theme</button>
</template>
<style scoped>
button {
text-transform: capitalize;
}
</style>When to use it
- Returning store or composable state that may only be changed through its actions
- Storing a class instance with private fields in reactive state without it throwing
- Serialising a large reactive object for an API call without proxy overhead
- Checking in a code review whether a value is a proxy, readonly or raw
More examples
A composable that exposes state read-only
Every write now goes through a function with a name, which is what makes a bug traceable: you grep for add, not for assignments to count. The same shape appears in a Pinia store that returns readonly state alongside its actions.
// src/composables/useBasket.ts
import { reactive, readonly } from 'vue'
const state = reactive({ count: 0, lines: [] as string[] })
export function useBasket() {
return {
// Callers can read and render this, but not assign to it.
state: readonly(state),
add(line: string): void {
state.lines.push(line)
state.count += 1
}
}
}
// src/demo/basket.ts
import { useBasket } from '../composables/useBasket'
const { state, add } = useBasket()
add('Lamp')
console.log(state.count, state.lines.length)
// Output: 1 1
// Refused: warns in development, ignored in production.
// state.count = 99toRaw, and what it costs
Use toRaw when you are only reading, or when a library insists on a plain object, for example before sending state over a WebSocket. Never keep the raw object around as a second handle on your state; the silent write in the last three lines is exactly the bug that costs an afternoon.
// src/demo/to-raw.ts
import { reactive, toRaw, isProxy } from 'vue'
const original = { id: 1, tags: ['new'] }
const state = reactive(original)
console.log(isProxy(state), toRaw(state) === original)
// Output: true true
// A read-only pass over a large structure with no proxy overhead.
const snapshot = JSON.stringify(toRaw(state))
console.log(snapshot)
// Output: {"id":1,"tags":["new"]}
// Writing through the raw object skips the set trap: no effect re-runs,
// even though the data did change.
toRaw(state).id = 2
console.log(state.id)
// Output: 2markRaw must come first
markRaw changes what happens the next time Vue is asked to proxy the object, so it has to be applied before the object is stored in reactive state. If a third-party instance is already misbehaving, mark it where it is created rather than at the point of use.
// src/demo/mark-raw.ts
import { reactive, markRaw, isReactive } from 'vue'
// Marked before it ever reaches reactive(): stays a plain Map.
const cache = markRaw(new Map<string, number>())
const good = reactive({ cache })
console.log(isReactive(good.cache))
// Output: false
// Not marked: reading it through the proxy returns a reactive Map,
// and Vue has cached that proxy for this object.
const other = new Map<string, number>()
const bad = reactive({ other })
console.log(isReactive(bad.other))
// Output: true
Discussion