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.
What You'll Learn
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/injectwithInjectionKey<T>so TypeScript catches a missing provider at compile time - Decide when
provide/injectis 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.
Contents
- Why this breaks: provide/inject shares the object, not a copy
- The mental model
- The fix: readonly() and a mutator function
- Typing it with InjectionKey
- Edge cases and gotchas
- Best practices
- FAQ
- Cheat sheet
- Key takeaways
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.
Why this breaks: provide/inject shares the object, not a copy
Here's the setup almost every provide/inject tutorial shows, including Vue's own introductory example:
<!-- 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>
<!-- 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
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: readonly() and a mutator function
The fix is two changes to the provider, and zero changes to how descendants read the value.
<!-- 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:
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 owncartfreely. It makes the injected reference refuse writes, which is what turns "any of nine components might have done this" back into "onlyaddItemcould 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.
Typing it with InjectionKey
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:
// 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");
// 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);
// 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.
Edge cases and gotchas
-
readonly()on arefwarns the same way.provide("count", readonly(count))meansinject("count").value = 5from a descendant logs the same "target is readonly" warning instead of applying. The pattern isn't specific toreactive()objects. -
computed()needs no wrapping. A computed ref is already read-only by construction — assigning to.valueis a TypeScript error (or a runtime no-op without a custom setter) regardless ofreadonly(). If the provided value is derived, not stored, provide thecomputed()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/injectacross 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.
Best Practices
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()(orshallowReadonly()) 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.
FAQ
Does readonly() also stop the provider from mutating the value?
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.
Does wrapping a large object in readonly() cost performance?
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.
Can a child still mutate the state through a method it calls?
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.
Does this replace Pinia?
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.
What happens if a component injects a key with no matching provider?
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).
Cheat Sheet
| Task | Code | Notes |
|---|---|---|
| Provide read-only state | provide(key, readonly(state)) |
Deep by default; descendants can read, not write |
| Provide the allowed mutation | provide(mutateKey, mutateFn) |
One named function per allowed change |
| Type the key | const key: InjectionKey<T> = Symbol("x") |
inject(key) is now typed `T \ |
| Inject with a safe default | {% raw %}inject(key, fallback)
|
Avoids undefined entirely when a sane default exists |
| Top-level-only protection | shallowReadonly(state) |
Rare — nested objects stay mutable |
| Readonly a single ref | provide(key, readonly(myRef)) |
Same warning-on-write behavior as an object |
// 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
🎮 Try it yourself
▶️ Open the interactive playground →
Runs right in your browser — poke at it and watch the concept react live.
🧠 Test yourself
Think it clicked? Take the 8-question quiz →
Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.
Key Takeaways
-
provide/injecthands 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()(orshallowReadonly()) 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| undefinedinstead 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.
📚 Read next
- Vue Composables: The Shared State Trap (+ Cheat Sheet)
- Vue defineModel: The v-model Contract, Explained
- Vue watch vs watchEffect: The Complete Guide
🚀 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:
- ⭐ GitHub — follow me and star the projects: github.com/parsajiravand
- 💬 Discord — join the frontend best-practices community: discord.gg/d9KRhuAwQ
- 📸 Instagram — frontend best practices, daily: @bestpractice___
Top comments (0)