← blog
VueNovember 2, 2026 · 12 min read

Vue provide/inject: Why You Should Never Skip readonly()

Vue's guide says wrap provide() state in readonly() — most tutorials skip it. The mutation bug that causes, verified against Vue 3.5, plus a cheat sheet.

Parsa Jiravand · Frontend engineer · building bestpractic
Vue provide/inject: Why You Should Never Skip readonly()

A teammate asks you why cart.total changed. You open the component that renders it — nothing there mutates it. You check its parent — nothing there either. Twenty minutes later you find it: a <PromoBanner> three levels down, nowhere near the cart, called cart.items.push(...) because it had a reference to the object and nobody stopped it. Vue didn't do anything wrong. provide/inject handed that component the exact same reactive object the cart store holds, and JavaScript let it write to what it was handed. The bug isn't in Vue's reactivity — it's in a contract nobody enforced.

By the end of this article you'll be able to:

  • Explain why an injected value is a live reference, not a snapshot or a message
  • Reproduce the "mutation from anywhere" bug on purpose, so you recognize it in a real codebase
  • Apply Vue's own fix — readonly() on the provided value, paired with an exposed mutator function — and know exactly what it does and doesn't stop
  • Type provide/inject with InjectionKey<T> so TypeScript catches a missing provider at compile time
  • Decide when provide/inject is still the right tool and when a store (Pinia) earns its weight

Who this is for: you've used ref, reactive, and basic provide/inject in a Vue 3 Composition API app, and you want the version of this pattern that survives more than two components.

This is written against Vue 3.5 (verified 3.5.44 on npm's latest tag, October 2026). Everything here is Composition API with <script setup>; there is no Options API equivalent worth writing, since this.$options.provide was always the weaker version of this same idea.

Here's the setup almost every provide/inject tutorial shows, including Vue's own introductory example:

Vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!-- CartProvider.vue --> <script setup> import { reactive, provide } from "vue"; const cart = reactive({ items: [], total: 0 }); function addItem(item) { cart.items.push(item); cart.total += item.price; } provide("cart", cart); provide("addItem", addItem); </script>
Vue
1
2
3
4
5
6
7
8
9
10
11
<!-- PromoBanner.vue, three components deep --> <script setup> import { inject } from "vue"; const cart = inject("cart"); function applyFreeGift() { // "I just need to add one thing, calling addItem felt like overkill" cart.items.push({ id: "free-sticker", price: 0 }); } </script>

Run this and it works. cart.items updates, the cart badge re-renders, nothing throws. That's exactly why it's dangerous: there is no error to notice, no test that fails, no lint rule that fires. inject("cart") returned the same reactive() proxy the provider created — not a message describing a change, not a frozen snapshot — the object itself. Anything with a reference to it can write to it, from anywhere in the tree, and Vue's reactivity system will faithfully propagate that write, because propagating writes is its entire job.

The bug only shows up later, as a debugging problem: cart.total is wrong, or cart.items has an entry nobody can explain, and the mutation could be coming from any of the components that called inject("cart"). Vue devtools' state inspector will show you the current value, but it won't point you at the nine places across the tree that are allowed to change it.

The mental model: provide/inject is Vue's version of passing a reference down an arbitrarily deep call stack — it shares the exact object, with none of the access control a function's parameters would give you. A prop is already a one-way, shallow-readonly-by-convention handoff that Vue warns about if you mutate it directly. provide/inject has no equivalent warning built in, because the whole point of the API is to skip the explicit, typed handoff of props — and that convenience is also the hole.

Vue's own guide is explicit about the fix, and about whose job it is: "it is recommended to keep any mutations to reactive state inside of the provider whenever possible," and when a descendant genuinely needs to change something, the provider should expose "a function that is responsible for mutating the state" rather than the state's writable surface. That single line is doing a lot of work, and it's easy to skip because unlike a type error, skipping it doesn't break anything today.

The fix is two changes to the provider, and zero changes to how descendants read the value.

Vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<!-- CartProvider.vue --> <script setup> import { reactive, readonly, provide } from "vue"; const cart = reactive({ items: [], total: 0 }); function addItem(item) { cart.items.push(item); cart.total += item.price; } // Descendants get a read-only view of the state... provide("cart", readonly(cart)); // ...and the only sanctioned way to change it. provide("addItem", addItem); </script>

Now the PromoBanner from earlier doesn't quietly succeed:

JavaScript
1
2
3
4
const cart = inject("cart"); cart.items.push({ id: "free-sticker", price: 0 }); // [Vue warn] Set operation on key "0" failed: target is readonly. // [Vue warn] Set operation on key "length" failed: target is readonly.

readonly() wraps the value in a second proxy that intercepts writes and, in development, logs a warning naming the exact key that was targeted instead of silently applying — Vue's reactivity docs show this same message for a plain mutation attempt on a readonly() copy of a reactive object. The write doesn't happen; the original, writable cart inside the provider is unaffected. PromoBanner has no way to change the cart except by calling the addItem function it was also given — which means every mutation path is now a named function you can grep for, breakpoint, or log.

Key concept: readonly() doesn't make the data immutable — the provider can still mutate its own cart freely. It makes the injected reference refuse writes, which is what turns "any of nine components might have done this" back into "only addItem could have done this."

readonly()'s wrapping is deep: nested objects and arrays reached through the readonly proxy are themselves readonly, so cart.items.push(...) is caught, not just reassigning cart.items itself. If you only need the top level protected (rare, and usually a sign the shape should be flatter), shallowReadonly() exists, but reach for plain readonly() by default — a one-level gap is exactly where the next PromoBanner finds the one key that still writes.

String keys ("cart") work but give TypeScript nothing to check — a typo in the key, or injecting before the provider ever renders, both fail silently at runtime. InjectionKey<T> fixes both:

TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// keys.ts import type { InjectionKey, DeepReadonly } from "vue"; export interface CartState { items: { id: string; price: number }[]; total: number; } // Keyed as DeepReadonly<CartState>, because that's what provide(cartKey, readonly(cart)) // actually hands out — typing it as plain CartState fails to compile, since readonly()'s // nested readonly arrays aren't assignable to CartState's mutable ones. export const cartKey: InjectionKey<DeepReadonly<CartState>> = Symbol("cart"); export const addItemKey: InjectionKey<(item: CartState["items"][number]) => void> = Symbol("addItem");
TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
// CartProvider.vue <script setup lang="ts"> import { reactive, readonly, provide } from "vue"; import { cartKey, addItemKey, type CartState } from "./keys"; const cart = reactive<CartState>({ items: [], total: 0 }); function addItem(item: CartState["items"][number]) { cart.items.push(item); cart.total += item.price; } provide(cartKey, readonly(cart)); provide(addItemKey, addItem);
TypeScript
1
2
3
4
5
6
7
8
// PromoBanner.vue <script setup lang="ts"> import { inject } from "vue"; import { cartKey, addItemKey } from "./keys"; // cart: DeepReadonly<CartState> | undefined — TypeScript now forces you to // handle the "no provider" case instead of finding it at 2am. const cart = inject(cartKey); const addItem = inject(addItemKey);

The symbol key means a typo can't collide with an unrelated provide("cart", ...) elsewhere in the tree, and inject()'s return type is now narrowed to DeepReadonly<CartState> | undefined — the undefined is real, because inject without a default genuinely returns undefined when there's no matching provider above the component, and the type system now makes you prove you've considered that.

  • readonly() on a ref warns the same way. provide("count", readonly(count)) means inject("count").value = 5 from a descendant logs the same "target is readonly" warning instead of applying. The pattern isn't specific to reactive() objects.
  • computed() needs no wrapping. A computed ref is already read-only by construction — assigning to .value is a TypeScript error (or a runtime no-op without a custom setter) regardless of readonly(). If the provided value is derived, not stored, provide the computed() directly.
  • The provider can still forget the function. readonly() stops the descendant from writing; it does nothing if the provider itself exports a mutable reference alongside the readonly one by mistake. The discipline is "provide state read-only, provide every allowed change as a named function" — both halves matter.
  • This is a dev-mode warning, not a runtime guarantee you should rely on for security. It protects you from accidental mutation in your own codebase, not from a determined caller bypassing Vue's proxy in production. Don't use it as an access-control boundary for untrusted code.
  • It's the single-request version of the shared-state trap. The same "one object, many writers" shape that bites provide/inject across a component tree also bites module-scope state across requests on a Vue SSR server — that's a different failure mode with a different fix, covered in this series' composables shared-state piece.

Reach for provide/inject (with this pattern) when the state is genuinely tree-scoped — a form wizard's shared step state, a modal's context, a design-system component's configuration — and you want to avoid prop-drilling it through components that don't care about it.

Pair every provide() of mutable state with:

  • readonly() (or shallowReadonly()) around the value itself.
  • One or more named functions for every change a descendant is allowed to make.
  • An InjectionKey<T> instead of a string, once more than one file touches the key.

Reach for Pinia instead when the state needs to be read or changed from components that aren't descendants of a single provider — a cart that's also read from the header nav and a checkout page outside that subtree, for instance. provide/inject is scoped to a component subtree by design; a store isn't. Using provide/inject for truly global, cross-tree state usually means re-providing it near the root anyway, at which point a store does the same job with less wiring.

No. readonly(cart) returns a new proxy; the original cart reference the provider holds is still the plain reactive() object, fully writable. Only the reference that goes through provide() is wrapped.

The wrapping itself is cheap — it's a proxy, created once, not a deep clone. Reads are tracked the same way reactive() reads are. You won't notice this on anything short of a very large, deeply nested object being read in a tight loop, and that's a different problem to measure separately.

Yes, and that's correct, not a hole. If the provider exposes addItem(), a child calling addItem() is mutating state through the one sanctioned path — that's the entire point. readonly() blocks direct property writes on the injected value; it was never meant to block the mutator functions you provide alongside it.

No — it's the right tool for state scoped to one part of the tree, not a general store replacement. See "Best practices" above for the line between them.

Without a default, inject() returns undefined at runtime. With InjectionKey<T>, TypeScript types that return as T | undefined, so a missing provider becomes a type error you fix during development instead of a crash a user finds. You can also pass a default as inject(key, defaultValue).

TaskCodeNotes
Provide read-only stateprovide(key, readonly(state))Deep by default; descendants can read, not write
Provide the allowed mutationprovide(mutateKey, mutateFn)One named function per allowed change
Type the keyconst key: InjectionKey<T> = Symbol("x")inject(key) is now typed T | undefined
Inject with a safe defaultinject(key, fallback)Avoids undefined entirely when a sane default exists
Top-level-only protectionshallowReadonly(state)Rare — nested objects stay mutable
Readonly a single refprovide(key, readonly(myRef))Same warning-on-write behavior as an object
TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// keys.ts import type { InjectionKey, DeepReadonly } from "vue"; export interface CartState { items: { id: string; price: number }[]; total: number } export const cartKey: InjectionKey<DeepReadonly<CartState>> = Symbol("cart"); export const addItemKey: InjectionKey<(item: CartState["items"][number]) => void> = Symbol("addItem"); // Provider const cart = reactive<CartState>({ items: [], total: 0 }); function addItem(item: CartState["items"][number]) { cart.items.push(item); cart.total += item.price; } provide(cartKey, readonly(cart)); provide(addItemKey, addItem); // Any descendant const cart = inject(cartKey); // DeepReadonly<CartState> | undefined const addItem = inject(addItemKey); // the only legal way to change it

Runs right in your browser — poke at it and watch the concept react live.

Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.

  • provide/inject hands descendants the live reactive object, not a copy — any component with the reference can mutate it, and Vue will propagate that write exactly as designed.
  • Vue's own guide already tells you the fix: keep mutations in the provider, and provide a function for anything a descendant is allowed to change. The gap is that most example code skips it.
  • readonly() (or shallowReadonly()) turns a silent, untraceable write into a dev-mode warning naming the exact key — wrap every provided value that descendants shouldn't mutate directly.
  • InjectionKey<T> replaces a magic string with a typed, typo-proof key and makes the "no provider above me" case a compile-time | undefined instead of a runtime surprise.
  • This isn't a Pinia replacement — it's the right shape for state scoped to one part of the tree; state read or written from outside that subtree belongs in a store instead.

That PromoBanner bug from the top is now impossible to write by accident: there's no writable cart to reach for, only an addItem call that shows up in a stack trace with your name on it. The next time inject() hands back an object, ask the one question this article is about: can whoever holds this write to it, and did you mean for them to?

What's the messiest provide/inject mutation bug you've chased down? Drop it in the comments — there's a good chance someone else is debugging the exact same shape right now.


🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.

Thanks for reading! Let's stay connected:

Keep reading

One post a day, in your inbox

Each one with a runnable playground and a quiz. No pitch, no digest, unsubscribe in one click.

0 comments

Sign in to join the discussion, like comments, and save articles for later.