[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"verticals":3,"quiz-vue-weekly-provide-inject-mutation-trap":44,"search-suggestions":60,"quiz-article-vue-weekly-provide-inject-mutation-trap":108,"path-membership-vue-weekly-provide-inject-mutation-trap":488},[4,20,32],{"id":5,"slug":6,"name":7,"tagline":8,"description":9,"accentFrom":10,"accentTo":11,"icon":12,"defaultLocale":13,"locales":14,"features":16,"position":19},"019fe637-3d33-714b-b57f-23e163ffca0c","dev","Web Development","Read it. Run it. Prove it.","A post a day on modern web development — most with an editable playground and a quiz that explains every answer. Free, no account needed.","violet-500","cyan-400","◇","en",[13,15],"fa",{"courses":17,"paths":17,"articles":17,"exams":18,"flashcards":18,"packages":17,"community":17,"certificates":17,"teams":17,"commerce":17},true,false,0,{"id":21,"slug":22,"name":23,"tagline":24,"description":25,"accentFrom":26,"accentTo":10,"icon":27,"defaultLocale":13,"locales":28,"features":30,"position":31},"019fe637-3dc2-754c-8657-0f175bfee7c6","lang","Languages","Learn a language the way you learn a codebase.","Grammar explained the way good documentation explains an API — one idea at a time, each with a quiz.","amber-400","⌘",[13,15,29],"es",{"courses":18,"paths":18,"articles":17,"exams":18,"flashcards":17,"packages":18,"community":17,"certificates":17,"teams":18,"commerce":18},2,{"id":33,"slug":34,"name":35,"tagline":36,"description":37,"accentFrom":38,"accentTo":39,"icon":40,"defaultLocale":13,"locales":41,"features":42,"position":43},"7b3c16f2-931d-410e-802e-e1fa4edab7de","soft","Soft Skills","The half of the job nobody wrote documentation for.","Weekly, on the parts of working life that decide more than your code does — first weeks, meetings, interviews, promotions, and the people around you. Written from what actually happens, and recorded as a podcast you can listen to on the walk.","emerald-400","teal-300","◉",[13],{"courses":18,"paths":18,"articles":17,"exams":18,"flashcards":18,"packages":18,"community":17,"certificates":18,"teams":18,"commerce":18},3,{"id":45,"slug":46,"kind":47,"title":48,"description":49,"config":50,"verticalId":5,"vertical":55,"course":52,"_count":56,"access":57,"attempts":59,"questionCount":51},"01a143f6-6294-7333-90ce-b2c937857827","vue-weekly-provide-inject-mutation-trap","PRACTICE_QUIZ","Vue provide\u002Finject: The Mutation Trap","Test what you just learned about provide\u002Finject's shared-reference behavior, readonly(), and typing it safely with InjectionKey.",{"questionCount":51,"timeLimitSec":52,"shuffleQuestions":18,"shuffleOptions":17,"negativeMarking":19,"passScorePct":53,"maxAttempts":52,"revealAnswers":54,"allowFlagging":18,"allowBacktracking":17},8,null,70,"AFTER_SUBMIT",{"slug":6,"name":7},{"questions":51},{"allowed":17,"reason":58},"FREE",[],[61,65,69,73,77,81,85,89,93,97,100,104],{"slug":62,"name":63,"articles":64},"webdev","Webdev",164,{"slug":66,"name":67,"articles":68},"javascript","Javascript",132,{"slug":70,"name":71,"articles":72},"frontend","Frontend",93,{"slug":74,"name":75,"articles":76},"tutorial","Tutorial",65,{"slug":78,"name":79,"articles":80},"css","Css",50,{"slug":82,"name":83,"articles":84},"performance","Performance",27,{"slug":86,"name":87,"articles":88},"typescript","Typescript",24,{"slug":90,"name":91,"articles":92},"react","React",20,{"slug":94,"name":95,"articles":96},"programming","Programming",16,{"slug":98,"name":99,"articles":96},"browser","Browser",{"slug":101,"name":102,"articles":103},"node","Node",14,{"slug":105,"name":106,"articles":107},"html","Html",11,{"id":109,"slug":46,"title":110,"subtitle":52,"excerpt":111,"coverUrl":112,"locale":13,"readingMinutes":113,"publishedAt":114,"viewCount":115,"likeCount":19,"commentCount":19,"author":116,"vertical":121,"topic":122,"tags":125,"_count":130,"playground":132,"body":134,"bodyMd":436,"seo":437,"translationGroupId":439,"series":440,"podcastUrl":52,"verticalId":5,"thread":479,"assessments":481,"translations":484,"quiz":486},"01a143f6-6257-7426-9268-cceca8db2069","Vue provide\u002Finject: 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.","\u002Fmedia\u002Fcovers\u002Fvue-weekly-provide-inject-mutation-trap.png",12,"2026-11-02T05:32:04.674Z",32,{"id":117,"name":118,"username":119,"avatarUrl":52,"headline":120},"019fe637-3c25-7088-9034-39c9f15dc3c8","Parsa Jiravand","parsa","Frontend engineer · building bestpractic",{"slug":6,"name":7,"accentFrom":10,"accentTo":11},{"slug":123,"name":124},"vue","Vue",[126,127,128,129],{"slug":123,"name":124,"color":52},{"slug":86,"name":87,"color":52},{"slug":74,"name":75,"color":52},{"slug":62,"name":63,"color":52},{"assessments":131},1,{"slug":46,"title":133},"Vue provide\u002Finject mutation trap — playground",{"blocks":135,"version":131},[136,140,145,148,157,160,163,175,178,181,184,189,193,196,199,202,205,208,211,214,218,221,226,229,233,236,239,242,247,251,255,258,261,269,272,275,278,284,287,290,293,296,299,302,305,308,311,314,317,320,323,355,359,362,365,368,371,374,377,380,383,386,389,392,400,403,406,409,412,418,421,424,427,430],{"id":137,"html":138,"type":139},"b1","\u003Cp>A teammate asks you why \u003Ccode>cart.total\u003C\u002Fcode> 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 \u003Ccode>&lt;PromoBanner&gt;\u003C\u002Fcode> three levels down, nowhere near the cart, called \u003Ccode>cart.items.push(...)\u003C\u002Fcode> because it had a reference to the object and nobody stopped it. Vue didn&#39;t do anything wrong. \u003Ccode>provide\u003C\u002Fcode>\u002F\u003Ccode>inject\u003C\u002Fcode> 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&#39;t in Vue&#39;s reactivity — it&#39;s in a contract nobody enforced.\u003C\u002Fp>","paragraph",{"id":141,"html":142,"text":143,"type":144,"level":31},"b2","What You&#39;ll Learn","What You'll Learn","heading",{"id":146,"html":147,"type":139},"b3","\u003Cp>By the end of this article you&#39;ll be able to:\u003C\u002Fp>",{"id":149,"type":150,"items":151,"ordered":18},"b4","list",[152,153,154,155,156],"Explain why an injected value is a live reference, not a snapshot or a message","Reproduce the &quot;mutation from anywhere&quot; bug on purpose, so you recognize it in a real codebase","Apply Vue&#39;s own fix — \u003Ccode>readonly()\u003C\u002Fcode> on the provided value, paired with an exposed mutator function — and know exactly what it does and doesn&#39;t stop","Type \u003Ccode>provide\u003C\u002Fcode>\u002F\u003Ccode>inject\u003C\u002Fcode> with \u003Ccode>InjectionKey&lt;T&gt;\u003C\u002Fcode> so TypeScript catches a missing provider at compile time","Decide when \u003Ccode>provide\u003C\u002Fcode>\u002F\u003Ccode>inject\u003C\u002Fcode> is still the right tool and when a store (Pinia) earns its weight",{"id":158,"html":159,"type":139},"b5","\u003Cp>\u003Cstrong>Who this is for:\u003C\u002Fstrong> you&#39;ve used \u003Ccode>ref\u003C\u002Fcode>, \u003Ccode>reactive\u003C\u002Fcode>, and basic \u003Ccode>provide\u003C\u002Fcode>\u002F\u003Ccode>inject\u003C\u002Fcode> in a Vue 3 Composition API app, and you want the version of this pattern that survives more than two components.\u003C\u002Fp>",{"id":161,"html":162,"text":162,"type":144,"level":31},"b6","Contents",{"id":164,"type":150,"items":165,"ordered":18},"b7",[166,167,168,169,170,171,172,173,174],"\u003Ca href=\"#why-this-breaks-provideinject-shares-the-object-not-a-copy\">Why this breaks: provide\u002Finject shares the object, not a copy\u003C\u002Fa>","\u003Ca href=\"#the-mental-model\">The mental model\u003C\u002Fa>","\u003Ca href=\"#the-fix-readonly-and-a-mutator-function\">The fix: readonly() and a mutator function\u003C\u002Fa>","\u003Ca href=\"#typing-it-with-injectionkey\">Typing it with InjectionKey\u003C\u002Fa>","\u003Ca href=\"#edge-cases-and-gotchas\">Edge cases and gotchas\u003C\u002Fa>","\u003Ca href=\"#best-practices\">Best practices\u003C\u002Fa>","\u003Ca href=\"#faq\">FAQ\u003C\u002Fa>","\u003Ca href=\"#cheat-sheet\">Cheat sheet\u003C\u002Fa>","\u003Ca href=\"#key-takeaways\">Key takeaways\u003C\u002Fa>",{"id":176,"html":177,"type":139},"b8","\u003Cp>This is written against \u003Cstrong>Vue 3.5\u003C\u002Fstrong> (verified \u003Ccode>3.5.44\u003C\u002Fcode> on npm&#39;s \u003Ccode>latest\u003C\u002Fcode> tag, October 2026). Everything here is Composition API with \u003Ccode>&lt;script setup&gt;\u003C\u002Fcode>; there is no Options API equivalent worth writing, since \u003Ccode>this.$options.provide\u003C\u002Fcode> was always the weaker version of this same idea.\u003C\u002Fp>",{"id":179,"html":180,"text":180,"type":144,"level":31},"b9","Why this breaks: provide\u002Finject shares the object, not a copy",{"id":182,"html":183,"type":139},"b10","\u003Cp>Here&#39;s the setup almost every \u003Ccode>provide\u003C\u002Fcode>\u002F\u003Ccode>inject\u003C\u002Fcode> tutorial shows, including Vue&#39;s own introductory example:\u003C\u002Fp>",{"id":185,"code":186,"type":187,"language":123,"highlight":188},"b11","\u003C!-- CartProvider.vue -->\n\u003Cscript setup>\nimport { reactive, provide } from \"vue\";\n\nconst cart = reactive({ items: [], total: 0 });\n\nfunction addItem(item) {\n  cart.items.push(item);\n  cart.total += item.price;\n}\n\nprovide(\"cart\", cart);\nprovide(\"addItem\", addItem);\n\u003C\u002Fscript>","code",[],{"id":190,"code":191,"type":187,"language":123,"highlight":192},"b12","\u003C!-- PromoBanner.vue, three components deep -->\n\u003Cscript setup>\nimport { inject } from \"vue\";\n\nconst cart = inject(\"cart\");\n\nfunction applyFreeGift() {\n  \u002F\u002F \"I just need to add one thing, calling addItem felt like overkill\"\n  cart.items.push({ id: \"free-sticker\", price: 0 });\n}\n\u003C\u002Fscript>",[],{"id":194,"html":195,"type":139},"b13","\u003Cp>Run this and it works. \u003Ccode>cart.items\u003C\u002Fcode> updates, the cart badge re-renders, nothing throws. That&#39;s exactly why it&#39;s dangerous: there is no error to notice, no test that fails, no lint rule that fires. \u003Ccode>inject(&quot;cart&quot;)\u003C\u002Fcode> returned the same \u003Ccode>reactive()\u003C\u002Fcode> 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&#39;s reactivity system will faithfully propagate that write, because propagating writes is its entire job.\u003C\u002Fp>",{"id":197,"html":198,"type":139},"b14","\u003Cp>The bug only shows up later, as a debugging problem: \u003Ccode>cart.total\u003C\u002Fcode> is wrong, or \u003Ccode>cart.items\u003C\u002Fcode> has an entry nobody can explain, and the mutation could be coming from any of the components that called \u003Ccode>inject(&quot;cart&quot;)\u003C\u002Fcode>. Vue devtools&#39; state inspector will show you the current value, but it won&#39;t point you at the nine places across the tree that are allowed to change it.\u003C\u002Fp>",{"id":200,"html":201,"text":201,"type":144,"level":31},"b15","The mental model",{"id":203,"html":204,"type":139},"b16","\u003Cp>\u003Cstrong>The mental model: \u003Ccode>provide\u003C\u002Fcode>\u002F\u003Ccode>inject\u003C\u002Fcode> is Vue&#39;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&#39;s parameters would give you.\u003C\u002Fstrong> A prop is already a one-way, shallow-readonly-by-convention handoff that Vue warns about if you mutate it directly. \u003Ccode>provide\u003C\u002Fcode>\u002F\u003Ccode>inject\u003C\u002Fcode> 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.\u003C\u002Fp>",{"id":206,"html":207,"type":139},"b17","\u003Cp>Vue&#39;s own guide is explicit about the fix, and about whose job it is: \u003Cem>&quot;it is recommended to keep any mutations to reactive state inside of the provider whenever possible,&quot;\u003C\u002Fem> and when a descendant genuinely needs to change something, the provider should expose \u003Cem>&quot;a function that is responsible for mutating the state&quot;\u003C\u002Fem> rather than the state&#39;s writable surface. That single line is doing a lot of work, and it&#39;s easy to skip because unlike a type error, skipping it doesn&#39;t break anything today.\u003C\u002Fp>",{"id":209,"html":210,"text":210,"type":144,"level":31},"b18","The fix: readonly() and a mutator function",{"id":212,"html":213,"type":139},"b19","\u003Cp>The fix is two changes to the provider, and zero changes to how descendants read the value.\u003C\u002Fp>",{"id":215,"code":216,"type":187,"language":123,"highlight":217},"b20","\u003C!-- CartProvider.vue -->\n\u003Cscript setup>\nimport { reactive, readonly, provide } from \"vue\";\n\nconst cart = reactive({ items: [], total: 0 });\n\nfunction addItem(item) {\n  cart.items.push(item);\n  cart.total += item.price;\n}\n\n\u002F\u002F Descendants get a read-only view of the state...\nprovide(\"cart\", readonly(cart));\n\u002F\u002F ...and the only sanctioned way to change it.\nprovide(\"addItem\", addItem);\n\u003C\u002Fscript>",[],{"id":219,"html":220,"type":139},"b21","\u003Cp>Now the \u003Ccode>PromoBanner\u003C\u002Fcode> from earlier doesn&#39;t quietly succeed:\u003C\u002Fp>",{"id":222,"code":223,"type":187,"language":224,"highlight":225},"b22","const cart = inject(\"cart\");\ncart.items.push({ id: \"free-sticker\", price: 0 });\n\u002F\u002F [Vue warn] Set operation on key \"0\" failed: target is readonly.\n\u002F\u002F [Vue warn] Set operation on key \"length\" failed: target is readonly.","js",[],{"id":227,"html":228,"type":139},"b23","\u003Cp>\u003Ccode>readonly()\u003C\u002Fcode> 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&#39;s reactivity docs show this same message for a plain mutation attempt on a \u003Ccode>readonly()\u003C\u002Fcode> copy of a reactive object. The write doesn&#39;t happen; the original, writable \u003Ccode>cart\u003C\u002Fcode> inside the provider is unaffected. \u003Ccode>PromoBanner\u003C\u002Fcode> has no way to change the cart except by calling the \u003Ccode>addItem\u003C\u002Fcode> function it was also given — which means every mutation path is now a named function you can grep for, breakpoint, or log.\u003C\u002Fp>",{"id":230,"html":231,"type":232},"b24","\u003Cp>\u003Cstrong>Key concept:\u003C\u002Fstrong> \u003Ccode>readonly()\u003C\u002Fcode> doesn&#39;t make the \u003Cem>data\u003C\u002Fem> immutable — the provider can still mutate its own \u003Ccode>cart\u003C\u002Fcode> freely. It makes the \u003Cem>injected reference\u003C\u002Fem> refuse writes, which is what turns &quot;any of nine components might have done this&quot; back into &quot;only \u003Ccode>addItem\u003C\u002Fcode> could have done this.&quot;\u003C\u002Fp>\n","quote",{"id":234,"html":235,"type":139},"b25","\u003Cp>\u003Ccode>readonly()\u003C\u002Fcode>&#39;s wrapping is deep: nested objects and arrays reached through the readonly proxy are themselves readonly, so \u003Ccode>cart.items.push(...)\u003C\u002Fcode> is caught, not just reassigning \u003Ccode>cart.items\u003C\u002Fcode> itself. If you only need the top level protected (rare, and usually a sign the shape should be flatter), \u003Ccode>shallowReadonly()\u003C\u002Fcode> exists, but reach for plain \u003Ccode>readonly()\u003C\u002Fcode> by default — a one-level gap is exactly where the next \u003Ccode>PromoBanner\u003C\u002Fcode> finds the one key that still writes.\u003C\u002Fp>",{"id":237,"html":238,"text":238,"type":144,"level":31},"b26","Typing it with InjectionKey",{"id":240,"html":241,"type":139},"b27","\u003Cp>String keys (\u003Ccode>&quot;cart&quot;\u003C\u002Fcode>) work but give TypeScript nothing to check — a typo in the key, or injecting before the provider ever renders, both fail silently at runtime. \u003Ccode>InjectionKey&lt;T&gt;\u003C\u002Fcode> fixes both:\u003C\u002Fp>",{"id":243,"code":244,"type":187,"language":245,"highlight":246},"b28","\u002F\u002F keys.ts\nimport type { InjectionKey, DeepReadonly } from \"vue\";\n\nexport interface CartState {\n  items: { id: string; price: number }[];\n  total: number;\n}\n\n\u002F\u002F Keyed as DeepReadonly\u003CCartState>, because that's what provide(cartKey, readonly(cart))\n\u002F\u002F actually hands out — typing it as plain CartState fails to compile, since readonly()'s\n\u002F\u002F nested readonly arrays aren't assignable to CartState's mutable ones.\nexport const cartKey: InjectionKey\u003CDeepReadonly\u003CCartState>> = Symbol(\"cart\");\nexport const addItemKey: InjectionKey\u003C(item: CartState[\"items\"][number]) => void> =\n  Symbol(\"addItem\");","ts",[],{"id":248,"code":249,"type":187,"language":245,"highlight":250},"b29","\u002F\u002F CartProvider.vue \u003Cscript setup lang=\"ts\">\nimport { reactive, readonly, provide } from \"vue\";\nimport { cartKey, addItemKey, type CartState } from \".\u002Fkeys\";\n\nconst cart = reactive\u003CCartState>({ items: [], total: 0 });\nfunction addItem(item: CartState[\"items\"][number]) {\n  cart.items.push(item);\n  cart.total += item.price;\n}\n\nprovide(cartKey, readonly(cart));\nprovide(addItemKey, addItem);",[],{"id":252,"code":253,"type":187,"language":245,"highlight":254},"b30","\u002F\u002F PromoBanner.vue \u003Cscript setup lang=\"ts\">\nimport { inject } from \"vue\";\nimport { cartKey, addItemKey } from \".\u002Fkeys\";\n\n\u002F\u002F cart: DeepReadonly\u003CCartState> | undefined — TypeScript now forces you to\n\u002F\u002F handle the \"no provider\" case instead of finding it at 2am.\nconst cart = inject(cartKey);\nconst addItem = inject(addItemKey);",[],{"id":256,"html":257,"type":139},"b31","\u003Cp>The symbol key means a typo can&#39;t collide with an unrelated \u003Ccode>provide(&quot;cart&quot;, ...)\u003C\u002Fcode> elsewhere in the tree, and \u003Ccode>inject()\u003C\u002Fcode>&#39;s return type is now narrowed to \u003Ccode>DeepReadonly&lt;CartState&gt; | undefined\u003C\u002Fcode> — the \u003Ccode>undefined\u003C\u002Fcode> is real, because \u003Ccode>inject\u003C\u002Fcode> without a default genuinely returns \u003Ccode>undefined\u003C\u002Fcode> when there&#39;s no matching provider above the component, and the type system now makes you prove you&#39;ve considered that.\u003C\u002Fp>",{"id":259,"html":260,"text":260,"type":144,"level":31},"b32","Edge cases and gotchas",{"id":262,"type":150,"items":263,"ordered":18},"b33",[264,265,266,267,268],"\u003Cstrong>\u003Ccode>readonly()\u003C\u002Fcode> on a \u003Ccode>ref\u003C\u002Fcode> warns the same way.\u003C\u002Fstrong> \u003Ccode>provide(&quot;count&quot;, readonly(count))\u003C\u002Fcode> means \u003Ccode>inject(&quot;count&quot;).value = 5\u003C\u002Fcode> from a descendant logs the same &quot;target is readonly&quot; warning instead of applying. The pattern isn&#39;t specific to \u003Ccode>reactive()\u003C\u002Fcode> objects.","\u003Cstrong>\u003Ccode>computed()\u003C\u002Fcode> needs no wrapping.\u003C\u002Fstrong> A computed ref is already read-only by construction — assigning to \u003Ccode>.value\u003C\u002Fcode> is a TypeScript error (or a runtime no-op without a custom setter) regardless of \u003Ccode>readonly()\u003C\u002Fcode>. If the provided value is derived, not stored, provide the \u003Ccode>computed()\u003C\u002Fcode> directly.","\u003Cstrong>The provider can still forget the function.\u003C\u002Fstrong> \u003Ccode>readonly()\u003C\u002Fcode> stops the \u003Cem>descendant\u003C\u002Fem> from writing; it does nothing if the provider itself exports a mutable reference alongside the readonly one by mistake. The discipline is &quot;provide state read-only, provide every allowed change as a named function&quot; — both halves matter.","\u003Cstrong>This is a dev-mode warning, not a runtime guarantee you should rely on for security.\u003C\u002Fstrong> It protects you from accidental mutation in your own codebase, not from a determined caller bypassing Vue&#39;s proxy in production. Don&#39;t use it as an access-control boundary for untrusted code.","\u003Cstrong>It&#39;s the single-request version of the shared-state trap.\u003C\u002Fstrong> The same &quot;one object, many writers&quot; shape that bites \u003Ccode>provide\u003C\u002Fcode>\u002F\u003Ccode>inject\u003C\u002Fcode> across a component tree also bites module-scope state across \u003Cem>requests\u003C\u002Fem> on a Vue SSR server — that&#39;s a different failure mode with a different fix, covered in this series&#39; \u003Ca href=\"https:\u002F\u002Fdev.to\u002Fparsajiravand\u002Fvue-composables-the-shared-state-trap-cheat-sheet-37ia\">composables shared-state piece\u003C\u002Fa>.",{"id":270,"html":271,"text":271,"type":144,"level":31},"b34","Best Practices",{"id":273,"html":274,"type":139},"b35","\u003Cp>\u003Cstrong>Reach for \u003Ccode>provide\u003C\u002Fcode>\u002F\u003Ccode>inject\u003C\u002Fcode> (with this pattern) when\u003C\u002Fstrong> the state is genuinely tree-scoped — a form wizard&#39;s shared step state, a modal&#39;s context, a design-system component&#39;s configuration — and you want to avoid prop-drilling it through components that don&#39;t care about it.\u003C\u002Fp>",{"id":276,"html":277,"type":139},"b36","\u003Cp>\u003Cstrong>Pair every \u003Ccode>provide()\u003C\u002Fcode> of mutable state with:\u003C\u002Fstrong>\u003C\u002Fp>",{"id":279,"type":150,"items":280,"ordered":17},"b37",[281,282,283],"\u003Ccode>readonly()\u003C\u002Fcode> (or \u003Ccode>shallowReadonly()\u003C\u002Fcode>) around the value itself.","One or more named functions for every change a descendant is allowed to make.","An \u003Ccode>InjectionKey&lt;T&gt;\u003C\u002Fcode> instead of a string, once more than one file touches the key.",{"id":285,"html":286,"type":139},"b38","\u003Cp>\u003Cstrong>Reach for Pinia instead when\u003C\u002Fstrong> the state needs to be read or changed from components that aren&#39;t descendants of a single provider — a cart that&#39;s also read from the header nav and a checkout page outside that subtree, for instance. \u003Ccode>provide\u003C\u002Fcode>\u002F\u003Ccode>inject\u003C\u002Fcode> is scoped to a component subtree by design; a store isn&#39;t. Using \u003Ccode>provide\u003C\u002Fcode>\u002F\u003Ccode>inject\u003C\u002Fcode> 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.\u003C\u002Fp>",{"id":288,"html":289,"text":289,"type":144,"level":31},"b39","FAQ",{"id":291,"html":292,"text":292,"type":144,"level":43},"b40","Does readonly() also stop the provider from mutating the value?",{"id":294,"html":295,"type":139},"b41","\u003Cp>No. \u003Ccode>readonly(cart)\u003C\u002Fcode> returns a \u003Cem>new\u003C\u002Fem> proxy; the original \u003Ccode>cart\u003C\u002Fcode> reference the provider holds is still the plain \u003Ccode>reactive()\u003C\u002Fcode> object, fully writable. Only the reference that goes through \u003Ccode>provide()\u003C\u002Fcode> is wrapped.\u003C\u002Fp>",{"id":297,"html":298,"text":298,"type":144,"level":43},"b42","Does wrapping a large object in readonly() cost performance?",{"id":300,"html":301,"type":139},"b43","\u003Cp>The wrapping itself is cheap — it&#39;s a proxy, created once, not a deep clone. Reads are tracked the same way \u003Ccode>reactive()\u003C\u002Fcode> reads are. You won&#39;t notice this on anything short of a very large, deeply nested object being read in a tight loop, and that&#39;s a different problem to measure separately.\u003C\u002Fp>",{"id":303,"html":304,"text":304,"type":144,"level":43},"b44","Can a child still mutate the state through a method it calls?",{"id":306,"html":307,"type":139},"b45","\u003Cp>Yes, and that&#39;s correct, not a hole. If the provider exposes \u003Ccode>addItem()\u003C\u002Fcode>, a child calling \u003Ccode>addItem()\u003C\u002Fcode> is mutating state through the one sanctioned path — that&#39;s the entire point. \u003Ccode>readonly()\u003C\u002Fcode> blocks direct property writes on the injected value; it was never meant to block the mutator functions you provide alongside it.\u003C\u002Fp>",{"id":309,"html":310,"text":310,"type":144,"level":43},"b46","Does this replace Pinia?",{"id":312,"html":313,"type":139},"b47","\u003Cp>No — it&#39;s the right tool for state scoped to one part of the tree, not a general store replacement. See &quot;Best practices&quot; above for the line between them.\u003C\u002Fp>",{"id":315,"html":316,"text":316,"type":144,"level":43},"b48","What happens if a component injects a key with no matching provider?",{"id":318,"html":319,"type":139},"b49","\u003Cp>Without a default, \u003Ccode>inject()\u003C\u002Fcode> returns \u003Ccode>undefined\u003C\u002Fcode> at runtime. With \u003Ccode>InjectionKey&lt;T&gt;\u003C\u002Fcode>, TypeScript types that return as \u003Ccode>T | undefined\u003C\u002Fcode>, 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 \u003Ccode>inject(key, defaultValue)\u003C\u002Fcode>.\u003C\u002Fp>",{"id":321,"html":322,"text":322,"type":144,"level":31},"b50","Cheat Sheet",{"id":324,"head":325,"rows":329,"type":354},"b51",[326,327,328],"Task","Code","Notes",[330,334,338,342,346,350],[331,332,333],"Provide read-only state","\u003Ccode>provide(key, readonly(state))\u003C\u002Fcode>","Deep by default; descendants can read, not write",[335,336,337],"Provide the allowed mutation","\u003Ccode>provide(mutateKey, mutateFn)\u003C\u002Fcode>","One named function per allowed change",[339,340,341],"Type the key","\u003Ccode>const key: InjectionKey&lt;T&gt; = Symbol(&quot;x&quot;)\u003C\u002Fcode>","\u003Ccode>inject(key)\u003C\u002Fcode> is now typed \u003Ccode>T | undefined\u003C\u002Fcode>",[343,344,345],"Inject with a safe default","\u003Ccode>inject(key, fallback)\u003C\u002Fcode>","Avoids \u003Ccode>undefined\u003C\u002Fcode> entirely when a sane default exists",[347,348,349],"Top-level-only protection","\u003Ccode>shallowReadonly(state)\u003C\u002Fcode>","Rare — nested objects stay mutable",[351,352,353],"Readonly a single ref","\u003Ccode>provide(key, readonly(myRef))\u003C\u002Fcode>","Same warning-on-write behavior as an object","table",{"id":356,"code":357,"type":187,"language":245,"highlight":358},"b52","\u002F\u002F keys.ts\nimport type { InjectionKey, DeepReadonly } from \"vue\";\nexport interface CartState { items: { id: string; price: number }[]; total: number }\nexport const cartKey: InjectionKey\u003CDeepReadonly\u003CCartState>> = Symbol(\"cart\");\nexport const addItemKey: InjectionKey\u003C(item: CartState[\"items\"][number]) => void> = Symbol(\"addItem\");\n\n\u002F\u002F Provider\nconst cart = reactive\u003CCartState>({ items: [], total: 0 });\nfunction addItem(item: CartState[\"items\"][number]) {\n  cart.items.push(item);\n  cart.total += item.price;\n}\nprovide(cartKey, readonly(cart));\nprovide(addItemKey, addItem);\n\n\u002F\u002F Any descendant\nconst cart = inject(cartKey);       \u002F\u002F DeepReadonly\u003CCartState> | undefined\nconst addItem = inject(addItemKey); \u002F\u002F the only legal way to change it",[],{"id":360,"html":361,"type":139},"b53","\u003C!-- playground:start -->",{"id":363,"html":364,"text":364,"type":144,"level":31},"b54","🎮 Try it yourself",{"id":366,"html":367,"type":139},"b55","\u003Cp>\u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fvue-weekly-provide-inject-mutation-trap\u002Fplayground\">▶️ Open the interactive playground →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>",{"id":369,"html":370,"type":139},"b56","\u003Cp>\u003Cem>Runs right in your browser — poke at it and watch the concept react live.\u003C\u002Fem>\u003C\u002Fp>",{"id":372,"html":373,"type":139},"b57","\u003C!-- playground:end -->",{"id":375,"html":376,"type":139},"b58","\u003C!-- quiz:start -->",{"id":378,"html":379,"text":379,"type":144,"level":31},"b59","🧠 Test yourself",{"id":381,"html":382,"type":139},"b60","\u003Cp>Think it clicked? \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fvue-weekly-provide-inject-mutation-trap\u002Fquiz\">Take the 8-question quiz →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>",{"id":384,"html":385,"type":139},"b61","\u003Cp>\u003Cem>Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.\u003C\u002Fem>\u003C\u002Fp>",{"id":387,"html":388,"type":139},"b62","\u003C!-- quiz:end -->",{"id":390,"html":391,"text":391,"type":144,"level":31},"b63","Key Takeaways",{"id":393,"type":150,"items":394,"ordered":18},"b64",[395,396,397,398,399],"\u003Ccode>provide\u003C\u002Fcode>\u002F\u003Ccode>inject\u003C\u002Fcode> 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&#39;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.","\u003Ccode>readonly()\u003C\u002Fcode> (or \u003Ccode>shallowReadonly()\u003C\u002Fcode>) turns a silent, untraceable write into a dev-mode warning naming the exact key — wrap every provided value that descendants shouldn&#39;t mutate directly.","\u003Ccode>InjectionKey&lt;T&gt;\u003C\u002Fcode> replaces a magic string with a typed, typo-proof key and makes the &quot;no provider above me&quot; case a compile-time \u003Ccode>| undefined\u003C\u002Fcode> instead of a runtime surprise.","This isn&#39;t a Pinia replacement — it&#39;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.",{"id":401,"html":402,"type":139},"b65","\u003Cp>That \u003Ccode>PromoBanner\u003C\u002Fcode> bug from the top is now impossible to write by accident: there&#39;s no writable \u003Ccode>cart\u003C\u002Fcode> to reach for, only an \u003Ccode>addItem\u003C\u002Fcode> call that shows up in a stack trace with your name on it. The next time \u003Ccode>inject()\u003C\u002Fcode> 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?\u003C\u002Fp>",{"id":404,"html":405,"type":139},"b66","\u003Cp>What&#39;s the messiest \u003Ccode>provide\u003C\u002Fcode>\u002F\u003Ccode>inject\u003C\u002Fcode> mutation bug you&#39;ve chased down? Drop it in the comments — there&#39;s a good chance someone else is debugging the exact same shape right now.\u003C\u002Fp>",{"id":407,"html":408,"type":139},"b67","\u003C!-- related:start -->",{"id":410,"html":411,"text":411,"type":144,"level":31},"b68","📚 Read next",{"id":413,"type":150,"items":414,"ordered":18},"b69",[415,416,417],"\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fvue-weekly-composables-shared-state-trap\">Vue Composables: The Shared State Trap (+ Cheat Sheet)\u003C\u002Fa>","\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fvue-weekly-definemodel-v-model-contract\">Vue defineModel: The v-model Contract, Explained\u003C\u002Fa>","\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fvue-weekly-watch-vs-watcheffect\">Vue watch vs watchEffect: The Complete Guide\u003C\u002Fa>",{"id":419,"html":420,"type":139},"b70","\u003C!-- related:end -->",{"id":422,"type":423},"b71","divider",{"id":425,"html":426,"type":139},"b72","\u003Cp>🚀 \u003Cstrong>Want more like this?\u003C\u002Fstrong> Every guide, playground, and quiz lives on \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002F\">bestpractic.org\u003C\u002Fa>\u003C\u002Fstrong> — open it and \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002F\">sign up free\u003C\u002Fa>\u003C\u002Fstrong> so the next one finds you.\u003C\u002Fp>",{"id":428,"html":429,"type":139},"b73","\u003Cp>\u003Cem>Thanks for reading! Let&#39;s stay connected:\u003C\u002Fem>\u003C\u002Fp>",{"id":431,"type":150,"items":432,"ordered":18},"b74",[433,434,435],"⭐ \u003Cstrong>GitHub\u003C\u002Fstrong> — follow me and star the projects: \u003Ca href=\"https:\u002F\u002Fgithub.com\u002Fparsajiravand\">github.com\u002Fparsajiravand\u003C\u002Fa>","💬 \u003Cstrong>Discord\u003C\u002Fstrong> — join the frontend best-practices community: \u003Ca href=\"https:\u002F\u002Fdiscord.gg\u002Fd9KRhuAwQ\">discord.gg\u002Fd9KRhuAwQ\u003C\u002Fa>","📸 \u003Cstrong>Instagram\u003C\u002Fstrong> — frontend best practices, daily: \u003Ca href=\"https:\u002F\u002Fwww.instagram.com\u002Fbestpractice___\u002F\">@bestpractice___\u003C\u002Fa>","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 `\u003CPromoBanner>` 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`\u002F`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.\n\n## What You'll Learn\n\nBy the end of this article you'll be able to:\n\n- Explain why an injected value is a live reference, not a snapshot or a message\n- Reproduce the \"mutation from anywhere\" bug on purpose, so you recognize it in a real codebase\n- 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\n- Type `provide`\u002F`inject` with `InjectionKey\u003CT>` so TypeScript catches a missing provider at compile time\n- Decide when `provide`\u002F`inject` is still the right tool and when a store (Pinia) earns its weight\n\n**Who this is for:** you've used `ref`, `reactive`, and basic `provide`\u002F`inject` in a Vue 3 Composition API app, and you want the version of this pattern that survives more than two components.\n\n## Contents\n\n- [Why this breaks: provide\u002Finject shares the object, not a copy](#why-this-breaks-provideinject-shares-the-object-not-a-copy)\n- [The mental model](#the-mental-model)\n- [The fix: readonly() and a mutator function](#the-fix-readonly-and-a-mutator-function)\n- [Typing it with InjectionKey](#typing-it-with-injectionkey)\n- [Edge cases and gotchas](#edge-cases-and-gotchas)\n- [Best practices](#best-practices)\n- [FAQ](#faq)\n- [Cheat sheet](#cheat-sheet)\n- [Key takeaways](#key-takeaways)\n\nThis is written against **Vue 3.5** (verified `3.5.44` on npm's `latest` tag, October 2026). Everything here is Composition API with `\u003Cscript setup>`; there is no Options API equivalent worth writing, since `this.$options.provide` was always the weaker version of this same idea.\n\n## Why this breaks: provide\u002Finject shares the object, not a copy\n\nHere's the setup almost every `provide`\u002F`inject` tutorial shows, including Vue's own introductory example:\n\n```vue\n\u003C!-- CartProvider.vue -->\n\u003Cscript setup>\nimport { reactive, provide } from \"vue\";\n\nconst cart = reactive({ items: [], total: 0 });\n\nfunction addItem(item) {\n  cart.items.push(item);\n  cart.total += item.price;\n}\n\nprovide(\"cart\", cart);\nprovide(\"addItem\", addItem);\n\u003C\u002Fscript>\n```\n\n```vue\n\u003C!-- PromoBanner.vue, three components deep -->\n\u003Cscript setup>\nimport { inject } from \"vue\";\n\nconst cart = inject(\"cart\");\n\nfunction applyFreeGift() {\n  \u002F\u002F \"I just need to add one thing, calling addItem felt like overkill\"\n  cart.items.push({ id: \"free-sticker\", price: 0 });\n}\n\u003C\u002Fscript>\n```\n\nRun 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.\n\nThe 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.\n\n## The mental model\n\n**The mental model: `provide`\u002F`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`\u002F`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.\n\nVue'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.\n\n## The fix: readonly() and a mutator function\n\nThe fix is two changes to the provider, and zero changes to how descendants read the value.\n\n```vue\n\u003C!-- CartProvider.vue -->\n\u003Cscript setup>\nimport { reactive, readonly, provide } from \"vue\";\n\nconst cart = reactive({ items: [], total: 0 });\n\nfunction addItem(item) {\n  cart.items.push(item);\n  cart.total += item.price;\n}\n\n\u002F\u002F Descendants get a read-only view of the state...\nprovide(\"cart\", readonly(cart));\n\u002F\u002F ...and the only sanctioned way to change it.\nprovide(\"addItem\", addItem);\n\u003C\u002Fscript>\n```\n\nNow the `PromoBanner` from earlier doesn't quietly succeed:\n\n```js\nconst cart = inject(\"cart\");\ncart.items.push({ id: \"free-sticker\", price: 0 });\n\u002F\u002F [Vue warn] Set operation on key \"0\" failed: target is readonly.\n\u002F\u002F [Vue warn] Set operation on key \"length\" failed: target is readonly.\n```\n\n`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.\n\n> **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.\"\n\n`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.\n\n## Typing it with InjectionKey\n\nString 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\u003CT>` fixes both:\n\n```ts\n\u002F\u002F keys.ts\nimport type { InjectionKey, DeepReadonly } from \"vue\";\n\nexport interface CartState {\n  items: { id: string; price: number }[];\n  total: number;\n}\n\n\u002F\u002F Keyed as DeepReadonly\u003CCartState>, because that's what provide(cartKey, readonly(cart))\n\u002F\u002F actually hands out — typing it as plain CartState fails to compile, since readonly()'s\n\u002F\u002F nested readonly arrays aren't assignable to CartState's mutable ones.\nexport const cartKey: InjectionKey\u003CDeepReadonly\u003CCartState>> = Symbol(\"cart\");\nexport const addItemKey: InjectionKey\u003C(item: CartState[\"items\"][number]) => void> =\n  Symbol(\"addItem\");\n```\n\n```ts\n\u002F\u002F CartProvider.vue \u003Cscript setup lang=\"ts\">\nimport { reactive, readonly, provide } from \"vue\";\nimport { cartKey, addItemKey, type CartState } from \".\u002Fkeys\";\n\nconst cart = reactive\u003CCartState>({ items: [], total: 0 });\nfunction addItem(item: CartState[\"items\"][number]) {\n  cart.items.push(item);\n  cart.total += item.price;\n}\n\nprovide(cartKey, readonly(cart));\nprovide(addItemKey, addItem);\n```\n\n```ts\n\u002F\u002F PromoBanner.vue \u003Cscript setup lang=\"ts\">\nimport { inject } from \"vue\";\nimport { cartKey, addItemKey } from \".\u002Fkeys\";\n\n\u002F\u002F cart: DeepReadonly\u003CCartState> | undefined — TypeScript now forces you to\n\u002F\u002F handle the \"no provider\" case instead of finding it at 2am.\nconst cart = inject(cartKey);\nconst addItem = inject(addItemKey);\n```\n\nThe 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\u003CCartState> | 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.\n\n## Edge cases and gotchas\n\n- **`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.\n- **`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.\n- **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.\n- **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.\n- **It's the single-request version of the shared-state trap.** The same \"one object, many writers\" shape that bites `provide`\u002F`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](https:\u002F\u002Fdev.to\u002Fparsajiravand\u002Fvue-composables-the-shared-state-trap-cheat-sheet-37ia).\n\n## Best Practices\n\n**Reach for `provide`\u002F`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.\n\n**Pair every `provide()` of mutable state with:**\n1. `readonly()` (or `shallowReadonly()`) around the value itself.\n2. One or more named functions for every change a descendant is allowed to make.\n3. An `InjectionKey\u003CT>` instead of a string, once more than one file touches the key.\n\n**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`\u002F`inject` is scoped to a component subtree by design; a store isn't. Using `provide`\u002F`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.\n\n## FAQ\n\n### Does readonly() also stop the provider from mutating the value?\n\nNo. `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.\n\n### Does wrapping a large object in readonly() cost performance?\n\nThe 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.\n\n### Can a child still mutate the state through a method it calls?\n\nYes, 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.\n\n### Does this replace Pinia?\n\nNo — 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.\n\n### What happens if a component injects a key with no matching provider?\n\nWithout a default, `inject()` returns `undefined` at runtime. With `InjectionKey\u003CT>`, 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)`.\n\n## Cheat Sheet\n\n| Task | Code | Notes |\n| --- | --- | --- |\n| Provide read-only state | `provide(key, readonly(state))` | Deep by default; descendants can read, not write |\n| Provide the allowed mutation | `provide(mutateKey, mutateFn)` | One named function per allowed change |\n| Type the key | `const key: InjectionKey\u003CT> = Symbol(\"x\")` | `inject(key)` is now typed `T \\| undefined` |\n| Inject with a safe default | `inject(key, fallback)` | Avoids `undefined` entirely when a sane default exists |\n| Top-level-only protection | `shallowReadonly(state)` | Rare — nested objects stay mutable |\n| Readonly a single ref | `provide(key, readonly(myRef))` | Same warning-on-write behavior as an object |\n\n```ts\n\u002F\u002F keys.ts\nimport type { InjectionKey, DeepReadonly } from \"vue\";\nexport interface CartState { items: { id: string; price: number }[]; total: number }\nexport const cartKey: InjectionKey\u003CDeepReadonly\u003CCartState>> = Symbol(\"cart\");\nexport const addItemKey: InjectionKey\u003C(item: CartState[\"items\"][number]) => void> = Symbol(\"addItem\");\n\n\u002F\u002F Provider\nconst cart = reactive\u003CCartState>({ items: [], total: 0 });\nfunction addItem(item: CartState[\"items\"][number]) {\n  cart.items.push(item);\n  cart.total += item.price;\n}\nprovide(cartKey, readonly(cart));\nprovide(addItemKey, addItem);\n\n\u002F\u002F Any descendant\nconst cart = inject(cartKey);       \u002F\u002F DeepReadonly\u003CCartState> | undefined\nconst addItem = inject(addItemKey); \u002F\u002F the only legal way to change it\n```\n\n\u003C!-- playground:start -->\n\n## 🎮 Try it yourself\n\n**[▶️ Open the interactive playground →](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fvue-weekly-provide-inject-mutation-trap\u002Fplayground)**\n\n_Runs right in your browser — poke at it and watch the concept react live._\n\n\u003C!-- playground:end -->\n\n\n\u003C!-- quiz:start -->\n\n## 🧠 Test yourself\n\nThink it clicked? **[Take the 8-question quiz →](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fvue-weekly-provide-inject-mutation-trap\u002Fquiz)**\n\n_Instant feedback, a hint on every question, and an explanation for each answer — right or wrong._\n\n\u003C!-- quiz:end -->\n## Key Takeaways\n\n- `provide`\u002F`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.\n- 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.\n- `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.\n- `InjectionKey\u003CT>` 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.\n- 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.\n\nThat `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?\n\nWhat's the messiest `provide`\u002F`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.\n\n\u003C!-- related:start -->\n\n## 📚 Read next\n\n- [Vue Composables: The Shared State Trap (+ Cheat Sheet)](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fvue-weekly-composables-shared-state-trap)\n- [Vue defineModel: The v-model Contract, Explained](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fvue-weekly-definemodel-v-model-contract)\n- [Vue watch vs watchEffect: The Complete Guide](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fvue-weekly-watch-vs-watcheffect)\n\n\u003C!-- related:end -->\n\n---\n\n🚀 **Want more like this?** Every guide, playground, and quiz lives on **[bestpractic.org](https:\u002F\u002Fbestpractic.org\u002F)** — open it and **[sign up free](https:\u002F\u002Fbestpractic.org\u002F)** so the next one finds you.\n\n*Thanks for reading! Let's stay connected:*\n\n- ⭐ **GitHub** — follow me and star the projects: [github.com\u002Fparsajiravand](https:\u002F\u002Fgithub.com\u002Fparsajiravand)\n- 💬 **Discord** — join the frontend best-practices community: [discord.gg\u002Fd9KRhuAwQ](https:\u002F\u002Fdiscord.gg\u002Fd9KRhuAwQ)\n- 📸 **Instagram** — frontend best practices, daily: [@bestpractice___](https:\u002F\u002Fwww.instagram.com\u002Fbestpractice___\u002F)",{"title":110,"canonical":438,"description":111},"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fvue-weekly-provide-inject-mutation-trap","01a143f6-6257-7426-9268-d21ce746cf29",{"name":441,"part":442,"total":442,"items":443},"Vue Deep Dive",9,[444,448,453,457,461,465,470,474,478],{"slug":445,"title":446,"publishedAt":447,"readingMinutes":103},"vue-weekly-reactivity-ref-vs-reactive","Vue Reactivity Explained: ref vs reactive (+ Cheat Sheet)","2026-08-24T13:57:38.080Z",{"slug":449,"title":450,"publishedAt":451,"readingMinutes":452},"vue-weekly-nexttick-batching-dom-updates","Vue nextTick Explained: How DOM Updates Are Batched","2026-09-14T12:19:00.606Z",13,{"slug":454,"title":455,"publishedAt":456,"readingMinutes":452},"vue-weekly-composables-shared-state-trap","Vue Composables: The Shared State Trap (+ Cheat Sheet)","2026-09-22T19:37:11.460Z",{"slug":458,"title":459,"publishedAt":460,"readingMinutes":113},"vue-weekly-computed-caching-invalidation","Vue computed(): What It Caches and When It Reruns","2026-09-30T12:10:53.362Z",{"slug":462,"title":463,"publishedAt":464,"readingMinutes":452},"vue-weekly-definemodel-v-model-contract","Vue defineModel: The v-model Contract, Explained","2026-10-05T06:11:02.623Z",{"slug":466,"title":467,"publishedAt":468,"readingMinutes":469},"vue-weekly-watch-vs-watcheffect","Vue watch vs watchEffect: The Complete Guide","2026-10-12T06:10:54.504Z",15,{"slug":471,"title":472,"publishedAt":473,"readingMinutes":469},"vue-weekly-reactivity-batch-watcher-throws","Vue 3.6: When a Watcher Throws, Your Other Watchers Stall","2026-10-19T06:10:59.563Z",{"slug":475,"title":476,"publishedAt":477,"readingMinutes":452},"vue-weekly-vfor-directive-unmounted-hook","Vue v-for: The Directive Unmounted Hook Bug, Fixed in 3.5.41","2026-10-26T05:39:58.065Z",{"slug":46,"title":110,"publishedAt":114,"readingMinutes":113},{"id":480,"locked":18},"01a143f6-6278-759e-abb7-d8d3f46ba8f1",[482],{"id":45,"slug":46,"title":48,"_count":483},{"questions":51},[485],{"locale":13,"slug":46},{"id":45,"slug":46,"title":48,"_count":487,"questionCount":51},{"questions":51},[]]