[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"verticals":3,"quiz-vue-weekly-vfor-directive-unmounted-hook":44,"search-suggestions":60,"quiz-article-vue-weekly-vfor-directive-unmounted-hook":108,"path-membership-vue-weekly-vfor-directive-unmounted-hook":462},[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},"01a11b9f-f1e5-703a-aa9f-d4e0d5b4ec6c","vue-weekly-vfor-directive-unmounted-hook","PRACTICE_QUIZ","Vue v-for Directive Unmounted Hook Bug","Nine questions on the compiler bug that could skip a custom directive's lifecycle hooks inside v-for, why it happened, the Vue 3.5.41 fix, and how it differs from the index-as-key mistake.",{"questionCount":51,"timeLimitSec":52,"shuffleQuestions":18,"shuffleOptions":17,"negativeMarking":19,"passScorePct":53,"maxAttempts":52,"revealAnswers":54,"allowFlagging":18,"allowBacktracking":17},9,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",154,{"slug":66,"name":67,"articles":68},"javascript","Javascript",128,{"slug":70,"name":71,"articles":72},"frontend","Frontend",88,{"slug":74,"name":75,"articles":76},"tutorial","Tutorial",60,{"slug":78,"name":79,"articles":80},"css","Css",45,{"slug":82,"name":83,"articles":84},"performance","Performance",26,{"slug":86,"name":87,"articles":88},"typescript","Typescript",22,{"slug":90,"name":91,"articles":92},"react","React",19,{"slug":94,"name":95,"articles":96},"programming","Programming",14,{"slug":98,"name":99,"articles":96},"browser","Browser",{"slug":101,"name":102,"articles":103},"node","Node",13,{"slug":105,"name":106,"articles":107},"accessibility","Accessibility",10,{"id":109,"slug":46,"title":110,"subtitle":52,"excerpt":111,"coverUrl":112,"locale":13,"readingMinutes":103,"publishedAt":113,"viewCount":114,"likeCount":19,"commentCount":19,"author":115,"vertical":120,"topic":121,"tags":124,"_count":129,"playground":131,"body":133,"bodyMd":414,"seo":415,"translationGroupId":417,"series":418,"podcastUrl":52,"verticalId":5,"thread":453,"assessments":455,"translations":458,"quiz":460},"01a11b9f-f19f-7057-9a83-d09cd7fb120e","Vue v-for: The Directive Unmounted Hook Bug, Fixed in 3.5.41","A custom directive's unmounted hook could silently skip firing inside a keyed v-for list. The directive unmounted hook bug, Vue's real fix, and a cheat sheet.","\u002Fmedia\u002Fcovers\u002Fvue-weekly-vfor-directive-unmounted-hook.png","2026-10-26T05:39:58.065Z",54,{"id":116,"name":117,"username":118,"avatarUrl":52,"headline":119},"019fe637-3c25-7088-9034-39c9f15dc3c8","Parsa Jiravand","parsa","Frontend engineer · building bestpractic",{"slug":6,"name":7,"accentFrom":10,"accentTo":11},{"slug":122,"name":123},"vue","Vue",[125,126,127,128],{"slug":122,"name":123,"color":52},{"slug":66,"name":67,"color":52},{"slug":74,"name":75,"color":52},{"slug":62,"name":63,"color":52},{"assessments":130},1,{"slug":46,"title":132},"Vue v-for directive unmounted hook — interactive playground",{"blocks":134,"version":130},[135,139,144,147,156,159,162,173,176,180,183,188,191,195,200,203,206,209,212,215,218,221,224,227,230,233,236,239,242,245,248,251,254,257,260,263,266,269,272,275,278,281,285,288,292,295,298,301,304,307,311,314,317,320,323,326,329,332,364,368,371,378,381,384,387,390,396,399,402,405,408],{"id":136,"html":137,"type":138},"b1","\u003Cp>You write a custom directive once — \u003Ccode>v-tooltip\u003C\u002Fcode>, \u003Ccode>v-click-outside\u003C\u002Fcode>, \u003Ccode>v-ripple\u003C\u002Fcode> — attach something in \u003Ccode>mounted()\u003C\u002Fcode>, tear it down in \u003Ccode>unmounted()\u003C\u002Fcode>, and trust the pair like you&#39;d trust \u003Ccode>addEventListener\u003C\u002Fcode>\u002F\u003Ccode>removeEventListener\u003C\u002Fcode>. Then you drop it onto a row inside a filterable \u003Ccode>v-for\u003C\u002Fcode> list. Filter an item out. No error. No warning. The row disappears from the screen — and on some Vue 3 versions, \u003Ccode>unmounted()\u003C\u002Fcode> never ran. The listener it attached is still live, attached to a DOM node nothing references anymore. This was a real bug in Vue&#39;s own compiler, open for years, fixed only in Vue 3.5.41 — and understanding \u003Cem>why\u003C\u002Fem> it happened teaches you more about how \u003Ccode>v-for\u003C\u002Fcode> actually works than the fix note ever will.\u003C\u002Fp>","paragraph",{"id":140,"html":141,"text":142,"type":143,"level":31},"b2","What You&#39;ll Learn","What You'll Learn","heading",{"id":145,"html":146,"type":138},"b3","\u003Cp>By the end of this article you&#39;ll be able to:\u003C\u002Fp>",{"id":148,"type":149,"items":150,"ordered":18},"b4","list",[151,152,153,154,155],"Explain why a custom directive&#39;s lifecycle hooks can be skipped for an element inside \u003Ccode>v-for\u003C\u002Fcode>, even though the same directive works fine outside a loop","Reproduce the symptom, confirm whether your installed Vue version is affected, and verify the fix","Describe what a compiler patch flag is and why \u003Ccode>NEED_PATCH\u003C\u002Fcode> specifically matters for refs, vnode hooks, and custom directives","Write directive-based cleanup you can trust inside a list, instead of discovering the gap in production","Tell this bug apart from unrelated \u003Ccode>v-for\u003C\u002Fcode> gotchas (duplicate keys, index-as-key) that look similar but have different causes",{"id":157,"html":158,"type":138},"b5","\u003Cp>\u003Cstrong>Who this is for:\u003C\u002Fstrong> you&#39;ve written a custom directive with \u003Ccode>app.directive()\u003C\u002Fcode> or a local \u003Ccode>directives\u003C\u002Fcode> option, and you&#39;ve used \u003Ccode>v-for\u003C\u002Fcode> with \u003Ccode>:key\u003C\u002Fcode>. If directives themselves are new to you, the \u003Ca href=\"https:\u002F\u002Fvuejs.org\u002Fguide\u002Freusable\u002Fcustom-directives.html\">Vue Guide to Custom Directives\u003C\u002Fa> is worth reading first — this article assumes you know what \u003Ccode>mounted\u003C\u002Fcode>\u002F\u003Ccode>unmounted\u003C\u002Fcode> hooks are, not how to write your first one.\u003C\u002Fp>",{"id":160,"html":161,"text":161,"type":143,"level":31},"b6","Table of Contents",{"id":163,"type":149,"items":164,"ordered":18},"b7",[165,166,167,168,169,170,171,172],"\u003Ca href=\"#the-problem-a-cleanup-hook-that-sometimes-doesnt-run\">The Problem: A Cleanup Hook That Sometimes Doesn&#39;t Run\u003C\u002Fa>","\u003Ca href=\"#the-mental-model-patch-flags-decide-what-the-runtime-bothers-to-visit\">The Mental Model: Patch Flags Decide What the Runtime Bothers to Visit\u003C\u002Fa>","\u003Ca href=\"#the-bug-the-issue-and-the-fix\">The Bug, the Issue, and the Fix\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":174,"html":175,"type":138},"b8","\u003Cp>This is written against \u003Cstrong>Vue 3.5.x\u003C\u002Fstrong> (verified 3.5.43, current on npm&#39;s \u003Ccode>latest\u003C\u002Fcode> tag as of this writing). Vue 3.6 is still a release candidate (\u003Ccode>3.6.0-rc.10\u003C\u002Fcode> on the \u003Ccode>rc\u003C\u002Fcode> tag) and isn&#39;t stable yet, so nothing here depends on it. The fix this article covers shipped in \u003Cstrong>Vue 3.5.41\u003C\u002Fstrong>, released August 5, 2026 — if your \u003Ccode>package.json\u003C\u002Fcode> pins anything older, you are likely affected (one of the two issues the fix closed was filed against 3.2.47, so this isn&#39;t a 3.5-only gap).\u003C\u002Fp>",{"id":177,"html":178,"text":179,"type":143,"level":31},"b9","The Problem: A Cleanup Hook That Sometimes Doesn&#39;t Run","The Problem: A Cleanup Hook That Sometimes Doesn't Run",{"id":181,"html":182,"type":138},"b10","\u003Cp>Say you&#39;ve written a small directive that logs its own lifecycle — a stand-in for anything real a directive might do (attach a listener, instantiate a third-party widget, start an observer):\u003C\u002Fp>",{"id":184,"code":185,"type":186,"language":122,"highlight":187},"b11","\u003C!-- the directive — nothing unusual about it -->\n\u003Cscript setup>\nconst vLog = {\n  mounted(el, binding) {\n    console.log(`mounted: row ${binding.value}`)\n  },\n  unmounted(el, binding) {\n    console.log(`unmounted: row ${binding.value}`)\n    \u002F\u002F in real code: removeEventListener, destroy a tooltip instance,\n    \u002F\u002F disconnect an observer — whatever mounted() set up\n  },\n}\n\u003C\u002Fscript>","code",[],{"id":189,"html":190,"type":138},"b12","\u003Cp>Used on a single element, it behaves exactly as documented: one \u003Ccode>mounted\u003C\u002Fcode> log when it appears, one \u003Ccode>unmounted\u003C\u002Fcode> log when it&#39;s removed. Now put the same directive on an element inside a keyed \u003Ccode>v-for\u003C\u002Fcode>, and remove an item from the middle of the list by filtering the underlying array:\u003C\u002Fp>",{"id":192,"code":193,"type":186,"language":122,"highlight":194},"b13","\u003Ctemplate>\n  \u003Cdiv v-for=\"row in visibleRows\" :key=\"row.id\" v-log=\"row.id\">\n    {{ row.label }}\n  \u003C\u002Fdiv>\n\u003C\u002Ftemplate>",[],{"id":196,"code":197,"type":186,"language":198,"highlight":199},"b14","\u002F\u002F remove row 2 from the data the list is built from\nvisibleRows.value = visibleRows.value.filter(r => r.id !== 2)","js",[],{"id":201,"html":202,"type":138},"b15","\u003Cp>On an affected Vue version, row 2&#39;s \u003Ccode>&lt;div&gt;\u003C\u002Fcode> disappears from the DOM exactly as expected — but the console only ever shows the \u003Ccode>mounted\u003C\u002Fcode> log for it, never \u003Ccode>unmounted\u003C\u002Fcode>. Nothing throws. Nothing warns. The directive&#39;s cleanup code simply never executes for that element, while the identical directive on a non-looped element works every time. That asymmetry — directive hooks are reliable alone, unreliable in a \u003Ccode>v-for\u003C\u002Fcode> — is the entire bug, and it&#39;s what makes it so easy to ship without noticing: your manual test on one element passes, and the loop case only misbehaves once a list gets edited in production.\u003C\u002Fp>",{"id":204,"html":205,"text":205,"type":143,"level":31},"b16","The Mental Model: Patch Flags Decide What the Runtime Bothers to Visit",{"id":207,"html":208,"type":138},"b17","\u003Cp>\u003Cstrong>The mental model:\u003C\u002Fstrong> at compile time, Vue&#39;s template compiler inspects every element and decides which ones are worth revisiting when the component re-renders. An element with no dynamic text, no bound class, no bound prop — nothing that could ever change — gets compiled as fully static, and the runtime skips it entirely on every future patch. That skip is most of why Vue is fast: it never diffs what it already knows can&#39;t change.\u003C\u002Fp>",{"id":210,"html":211,"type":138},"b18","\u003Cp>To make that work, the compiler attaches a \u003Cstrong>patch flag\u003C\u002Fstrong> to every dynamic element — a small integer bitmask recording \u003Cem>which\u003C\u002Fem> kind of dynamic content it has: \u003Ccode>TEXT\u003C\u002Fcode> for interpolated text, \u003Ccode>CLASS\u003C\u002Fcode>\u002F\u003Ccode>STYLE\u003C\u002Fcode>\u002F\u003Ccode>PROPS\u003C\u002Fcode> for bound attributes, and so on. The runtime reads the flag instead of diffing blindly, so it only compares the parts of the vnode the flag says can change.\u003C\u002Fp>",{"id":213,"html":214,"type":138},"b19","\u003Cp>Now consider an element that has no dynamic text or props at all — only a \u003Ccode>ref\u003C\u002Fcode>, a \u003Ccode>v-bind\u003C\u002Fcode> to a vnode hook, or (as in the example above) a custom directive. Nothing about its \u003Cem>rendered output\u003C\u002Fem> ever changes, but Vue still needs to \u003Cstrong>visit\u003C\u002Fstrong> it on every patch, because a \u003Ccode>ref\u003C\u002Fcode> needs updating, a vnode hook needs calling, or a directive&#39;s \u003Ccode>updated\u003C\u002Fcode>\u002F\u003Ccode>unmounted\u003C\u002Fcode> hook needs invoking at the right moment. That&#39;s exactly what the \u003Ccode>NEED_PATCH\u003C\u002Fcode> flag exists for: it tells the runtime &quot;nothing here looks different, but visit it anyway — something non-visual depends on it.&quot;\u003C\u002Fp>",{"id":216,"html":217,"type":138},"b20","\u003Cp>The bug was that \u003Cstrong>\u003Ccode>NEED_PATCH\u003C\u002Fcode> could get dropped specifically when that same element was also inside a \u003Ccode>v-for\u003C\u002Fcode>.\u003C\u002Fstrong> The compiler&#39;s own \u003Ccode>v-for\u003C\u002Fcode>-specific optimizations and its patch-flag analysis for refs\u002Fhooks\u002Fdirectives didn&#39;t compose correctly, so an element that would have correctly carried \u003Ccode>NEED_PATCH\u003C\u002Fcode> on its own lost that flag once it was also a loop child. Without the flag, the runtime had no signal telling it to visit the element during a patch — so the directive&#39;s lifecycle hook for that specific element silently went uninvoked.\u003C\u002Fp>",{"id":219,"html":220,"text":220,"type":143,"level":31},"b21","The Bug, the Issue, and the Fix",{"id":222,"html":223,"type":138},"b22","\u003Cp>This wasn&#39;t a hunch — it&#39;s two tracked issues and a merged fix in Vue&#39;s own repository. \u003Ca href=\"https:\u002F\u002Fgithub.com\u002Fvuejs\u002Fcore\u002Fissues\u002F12569\">Issue #12569\u003C\u002Fa>, titled &quot;Custom directive&#39;s unmounted event is not triggered on v-for element,&quot; reported exactly the symptom above. \u003Ca href=\"https:\u002F\u002Fgithub.com\u002Fvuejs\u002Fcore\u002Fissues\u002F9239\">Issue #9239\u003C\u002Fa> reported the same underlying gap for \u003Ccode>ref\u003C\u002Fcode> callbacks on \u003Ccode>v-for\u003C\u002Fcode> children — the flag loss wasn&#39;t directive-specific, it affected every \u003Ccode>NEED_PATCH\u003C\u002Fcode>-only case: refs, vnode hooks, and custom directives alike.\u003C\u002Fp>",{"id":225,"html":226,"type":138},"b23","\u003Cp>The maintainer who fixed it, in \u003Ca href=\"https:\u002F\u002Fgithub.com\u002Fvuejs\u002Fcore\u002Fpull\u002F11682\">PR #11682\u003C\u002Fa> (&quot;preserve vnode lifecycle in stable v-for&quot;), described the bug precisely: &quot;When an element uses \u003Ccode>ref\u003C\u002Fcode>, \u003Ccode>vnodeHook\u003C\u002Fcode>, or custom directives, the \u003Ccode>patchFlag\u003C\u002Fcode> should be \u003Ccode>NEED_PATCH\u003C\u002Fcode>. However, when they are used together with \u003Ccode>v-for\u003C\u002Fcode>, the \u003Ccode>patchFlag\u003C\u002Fcode> gets lost.&quot; The fix tracks whether an element needs patching separately from whether it needs to stay a full &quot;block&quot; in the compiler&#39;s internal tree, and the \u003Ccode>v-for\u003C\u002Fcode> transform now adds \u003Ccode>NEED_PATCH\u003C\u002Fcode> back wherever one of these lifecycle-sensitive features is present. It shipped in \u003Cstrong>Vue 3.5.41\u003C\u002Fstrong> (2026-08-05).\u003C\u002Fp>",{"id":228,"html":229,"type":138},"b24","\u003Cp>What that means for you in practice: if your \u003Ccode>package.json\u003C\u002Fcode> resolves \u003Ccode>vue\u003C\u002Fcode> to \u003Ccode>3.5.41\u003C\u002Fcode> or later — or anything on the current \u003Ccode>3.5.43\u003C\u002Fcode> — this is already fixed, and the example above behaves correctly: both \u003Ccode>mounted\u003C\u002Fcode> and \u003Ccode>unmounted\u003C\u002Fcode> fire for every row, every time. If you&#39;re pinned to an older \u003Ccode>3.5.x\u003C\u002Fcode> release, or anywhere earlier in the Vue 3 line (one of the two issues this fix closed, #9239, was reported against 3.2.47), the gap is real and silent. The straightforward fix is upgrading to 3.5.41 or later; this article doesn&#39;t catalog a non-upgrade workaround, since the bug lives in the compiler&#39;s static analysis rather than in anything your component code controls — check the linked GitHub issues directly if you need to stay pinned for now.\u003C\u002Fp>",{"id":231,"html":232,"type":138},"b25","\u003C!-- playground:start -->",{"id":234,"html":235,"text":235,"type":143,"level":31},"b26","🎮 Try it yourself",{"id":237,"html":238,"type":138},"b27","\u003Cp>\u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fvue-weekly-vfor-directive-unmounted-hook\u002Fplayground\">▶️ Open the interactive playground →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>",{"id":240,"html":241,"type":138},"b28","\u003Cp>\u003Cem>Runs right in your browser — poke at it and watch the concept react live.\u003C\u002Fem>\u003C\u002Fp>",{"id":243,"html":244,"type":138},"b29","\u003C!-- playground:end -->",{"id":246,"html":247,"text":247,"type":143,"level":31},"b30","Edge Cases and Gotchas",{"id":249,"html":250,"type":138},"b31","\u003Cp>\u003Cstrong>This is not the index-as-key bug.\u003C\u002Fstrong> A separate, much more famous \u003Ccode>v-for\u003C\u002Fcode> mistake — using the array index as \u003Ccode>:key\u003C\u002Fcode> — causes Vue to \u003Cem>reuse\u003C\u002Fem> the wrong DOM node and misattribute state across reorders. That&#39;s a key-strategy problem you control by choosing a stable \u003Ccode>:key\u003C\u002Fcode>. The bug in this article happens even with a perfectly correct, stable \u003Ccode>:key\u003C\u002Fcode> — it&#39;s a compiler flag that was lost, not a key you chose wrong.\u003C\u002Fp>",{"id":252,"html":253,"type":138},"b32","\u003Cp>\u003Cstrong>\u003Ccode>v-if\u003C\u002Fcode> and \u003Ccode>v-for\u003C\u002Fcode> on different elements of the same loop are unaffected.\u003C\u002Fstrong> This specifically concerned &quot;stable&quot; \u003Ccode>v-for\u003C\u002Fcode> — a keyed loop with no structural directive (\u003Ccode>v-if\u003C\u002Fcode>\u002F\u003Ccode>v-for\u003C\u002Fcode>) on the same element changing the number of children per iteration independent of the list itself. Combining \u003Ccode>v-for\u003C\u002Fcode> with \u003Ccode>v-if\u003C\u002Fcode> on the same tag is already discouraged by Vue&#39;s own style guide, and can throw a runtime reference error if the \u003Ccode>v-if\u003C\u002Fcode> expression depends on the \u003Ccode>v-for\u003C\u002Fcode>&#39;s loop variable — a separate, unrelated problem, not this bug.\u003C\u002Fp>",{"id":255,"html":256,"type":138},"b33","\u003Cp>\u003Cstrong>Built-in reactive bindings were never affected.\u003C\u002Fstrong> \u003Ccode>v-model\u003C\u002Fcode>, \u003Ccode>v-show\u003C\u002Fcode>, bound \u003Ccode>:class\u003C\u002Fcode>\u002F\u003Ccode>:style\u003C\u002Fcode>\u002Fprops, and interpolated text all carry their own patch flags (\u003Ccode>PROPS\u003C\u002Fcode>, \u003Ccode>STYLE\u003C\u002Fcode>, \u003Ccode>CLASS\u003C\u002Fcode>, \u003Ccode>TEXT\u003C\u002Fcode>) regardless of \u003Ccode>v-for\u003C\u002Fcode>. The gap was specific to elements whose \u003Cem>only\u003C\u002Fem> dynamic feature was a \u003Ccode>ref\u003C\u002Fcode>, a vnode hook, or a custom directive, with nothing else forcing a patch flag already.\u003C\u002Fp>",{"id":258,"html":259,"type":138},"b34","\u003Cp>\u003Cstrong>\u003Ccode>ref\u003C\u002Fcode> callbacks silently missing inside \u003Ccode>v-for\u003C\u002Fcode> was the same bug.\u003C\u002Fstrong> If you&#39;ve ever seen a \u003Ccode>ref\u003C\u002Fcode> array (\u003Ccode>:ref=&quot;(el) =&gt; refs.push(el)&quot;\u003C\u002Fcode>) inside a loop skip an entry or hold a stale element after a list changed, issue #9239 describes the identical mechanism — not a separate problem to debug.\u003C\u002Fp>",{"id":261,"html":262,"type":138},"b35","\u003Cp>\u003Cstrong>Custom directives using only \u003Ccode>created\u003C\u002Fcode>\u002F\u003Ccode>beforeMount\u003C\u002Fcode>\u002F\u003Ccode>beforeUpdate\u003C\u002Fcode> hooks were affected identically to \u003Ccode>mounted\u003C\u002Fcode>\u002F\u003Ccode>unmounted\u003C\u002Fcode>.\u003C\u002Fstrong> The bug was about the patch flag being present at all, not about which specific directive hook fires — every hook on the affected element went uninvoked on the patches where the flag was missing.\u003C\u002Fp>",{"id":264,"html":265,"text":265,"type":143,"level":31},"b36","Best Practices",{"id":267,"html":268,"type":138},"b37","\u003Cp>\u003Cstrong>Reach for this knowledge when\u003C\u002Fstrong> you maintain any directive that does real cleanup — removing listeners, destroying a third-party widget instance, disconnecting a \u003Ccode>ResizeObserver\u003C\u002Fcode> or \u003Ccode>MutationObserver\u003C\u002Fcode> — and that directive is ever used inside a list. Confirm your installed Vue is \u003Ccode>3.5.41\u003C\u002Fcode> or later before trusting that cleanup unconditionally.\u003C\u002Fp>",{"id":270,"html":271,"type":138},"b38","\u003Cp>\u003Cstrong>Avoid relying on directive lifecycle hooks for anything correctness-critical\u003C\u002Fstrong> even after upgrading. Directives are the right tool for DOM-only side effects (focus management, third-party widget wiring, scroll\u002Fintersection observers) — not for application state that must be perfectly consistent, where a \u003Ccode>watch\u003C\u002Fcode> or computed-driven reactive update gives you a mechanism entirely outside the compiler&#39;s patch-flag analysis.\u003C\u002Fp>",{"id":273,"html":274,"type":138},"b39","\u003Cp>\u003Cstrong>Write a real regression test for list-cleanup directives\u003C\u002Fstrong>, not just a manual click-through: mount a list with the directive, remove an item, and assert the directive&#39;s \u003Ccode>unmounted\u003C\u002Fcode> hook actually ran for it. This bug shipped silently for years precisely because it only reproduces once an item leaves a loop — a test that only ever mounts, never removes, would never have caught it.\u003C\u002Fp>",{"id":276,"html":277,"type":138},"b40","\u003Cp>\u003Cstrong>Pin and read your Vue version deliberately\u003C\u002Fstrong>, especially on a long-lived app. \u003Ccode>npm ls vue\u003C\u002Fcode> (or checking the lockfile) tells you exactly what you&#39;re running; \u003Ccode>npm view vue dist-tags\u003C\u002Fcode> tells you what&#39;s current. A multi-year-old pin anywhere before \u003Ccode>3.5.41\u003C\u002Fcode> is exactly the situation this bug could still be lurking in.\u003C\u002Fp>",{"id":279,"html":280,"text":280,"type":143,"level":31},"b41","FAQ",{"id":282,"html":283,"text":284,"type":143,"level":43},"b42","Why didn&#39;t Vue throw an error or a dev-mode warning for this?","Why didn't Vue throw an error or a dev-mode warning for this?",{"id":286,"html":287,"type":138},"b43","\u003Cp>Because nothing about the element&#39;s own rendering was wrong — the DOM node itself unmounted correctly along with its parent list item. The only thing missing was Vue&#39;s internal signal to also invoke the directive&#39;s hook on that specific vnode. There was no incorrect state for Vue to detect and warn about; the gap was an omission in the compiler&#39;s static analysis, not a runtime contradiction it could catch.\u003C\u002Fp>",{"id":289,"html":290,"text":291,"type":143,"level":43},"b44","Does this affect \u003Ccode>v-model\u003C\u002Fcode> or other built-in directives?","Does this affect v-model or other built-in directives?",{"id":293,"html":294,"type":138},"b45","\u003Cp>No. \u003Ccode>v-model\u003C\u002Fcode> compiles to a bound prop plus an event listener, both of which already carry their own patch flags (\u003Ccode>PROPS\u003C\u002Fcode>) independent of this bug. The gap only touched elements whose \u003Cem>sole\u003C\u002Fem> dynamic feature was a \u003Ccode>ref\u003C\u002Fcode>, a vnode hook, or a \u003Cstrong>custom\u003C\u002Fstrong> directive with nothing else forcing a patch flag.\u003C\u002Fp>",{"id":296,"html":297,"text":297,"type":143,"level":43},"b46","Is Nuxt affected the same way?",{"id":299,"html":300,"type":138},"b47","\u003Cp>Yes, to the extent any given Nuxt app&#39;s resolved \u003Ccode>vue\u003C\u002Fcode> dependency was on an affected version — Nuxt renders on top of Vue&#39;s own core, so this is a Vue-core fix, not something Nuxt layers on. Check \u003Ccode>vue\u003C\u002Fcode>&#39;s resolved version in your Nuxt project&#39;s lockfile the same way you would in a plain Vue app.\u003C\u002Fp>",{"id":302,"html":303,"text":303,"type":143,"level":43},"b48","Is this fixed in Vue 2?",{"id":305,"html":306,"type":138},"b49","\u003Cp>Not applicable — Vue 2&#39;s compiler doesn&#39;t use this patch-flag\u002Fblock system at all (it was introduced with Vue 3&#39;s rewritten renderer), so the bug and its fix are both Vue-3-specific.\u003C\u002Fp>",{"id":308,"html":309,"text":310,"type":143,"level":43},"b50","How do I confirm my own app isn&#39;t exposed?","How do I confirm my own app isn't exposed?",{"id":312,"html":313,"type":138},"b51","\u003Cp>Check your resolved \u003Ccode>vue\u003C\u002Fcode> version (\u003Ccode>npm ls vue\u003C\u002Fcode>), and if it&#39;s below \u003Ccode>3.5.41\u003C\u002Fcode>, reproduce the repro in this article in a scratch component before deciding whether to upgrade immediately or schedule it — a directive that only logs, with no real side effect, costs nothing to leave broken; one that owns a listener or a third-party instance is worth fixing promptly.\u003C\u002Fp>",{"id":315,"html":316,"type":138},"b52","\u003C!-- quiz:start -->",{"id":318,"html":319,"text":319,"type":143,"level":31},"b53","🧠 Test yourself",{"id":321,"html":322,"type":138},"b54","\u003Cp>Think it clicked? \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fvue-weekly-vfor-directive-unmounted-hook\u002Fquiz\">Take the 9-question quiz →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>",{"id":324,"html":325,"type":138},"b55","\u003Cp>\u003Cem>Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.\u003C\u002Fem>\u003C\u002Fp>",{"id":327,"html":328,"type":138},"b56","\u003C!-- quiz:end -->",{"id":330,"html":331,"text":331,"type":143,"level":31},"b57","Cheat Sheet",{"id":333,"head":334,"rows":338,"type":363},"b58",[335,336,337],"Task","What to check","Notes",[339,343,347,351,355,359],[340,341,342],"Confirm you&#39;re affected","\u003Ccode>npm ls vue\u003C\u002Fcode>","Affected: any Vue 3 release before \u003Ccode>3.5.41\u003C\u002Fcode> (reported as far back as 3.2.47)",[344,345,346],"Confirm you&#39;re fixed","Vue \u003Ccode>≥ 3.5.41\u003C\u002Fcode> (current: \u003Ccode>3.5.43\u003C\u002Fcode>)","No flag, no config — the fix is purely internal",[348,349,350],"Reproduce the symptom","Custom directive + keyed \u003Ccode>v-for\u003C\u002Fcode> + remove an item","Log \u003Ccode>mounted\u003C\u002Fcode>\u002F\u003Ccode>unmounted\u003C\u002Fcode>; on affected versions only \u003Ccode>mounted\u003C\u002Fcode> appears",[352,353,354],"Tell it apart from index-as-key bugs","Does it happen with a correct, stable \u003Ccode>:key\u003C\u002Fcode>?","This bug happens even with a correct key; index-as-key is a separate mistake",[356,357,358],"Root cause, one line","\u003Ccode>NEED_PATCH\u003C\u002Fcode> patch flag dropped for \u003Ccode>ref\u003C\u002Fcode>\u002Fvnode-hook\u002Fdirective-only elements inside \u003Ccode>v-for\u003C\u002Fcode>","\u003Ca href=\"https:\u002F\u002Fgithub.com\u002Fvuejs\u002Fcore\u002Fpull\u002F11682\">vuejs\u002Fcore#11682\u003C\u002Fa>",[360,361,362],"What&#39;s NOT affected","\u003Ccode>v-model\u003C\u002Fcode>, bound \u003Ccode>:class\u003C\u002Fcode>\u002F\u003Ccode>:style\u003C\u002Fcode>\u002Fprops, interpolated text","These already carry their own patch flags regardless of \u003Ccode>v-for\u003C\u002Fcode>","table",{"id":365,"code":366,"type":186,"language":198,"highlight":367},"b59","\u002F\u002F A directive you can trust inside v-for, once Vue is ≥ 3.5.41:\nconst vCleanupCheck = {\n  mounted(el, binding) {\n    el.__cleanup = () => console.log(`cleaned up row ${binding.value}`)\n  },\n  unmounted(el, binding) {\n    el.__cleanup?.()\n  },\n}\n\u002F\u002F Regression-test it: mount a list, remove an item, assert unmounted ran.",[],{"id":369,"html":370,"text":370,"type":143,"level":31},"b60","Key Takeaways",{"id":372,"type":149,"items":373,"ordered":18},"b61",[374,375,376,377],"A custom directive&#39;s \u003Ccode>mounted\u003C\u002Fcode>\u002F\u003Ccode>unmounted\u003C\u002Fcode> hooks could silently fail to fire for an element inside a keyed \u003Ccode>v-for\u003C\u002Fcode>, with no error or warning — fixed in Vue 3.5.41.","The cause was a compiler detail: elements whose only dynamic feature is a \u003Ccode>ref\u003C\u002Fcode>, a vnode hook, or a custom directive need the \u003Ccode>NEED_PATCH\u003C\u002Fcode> flag to be visited during a patch, and that flag could get lost specifically when combined with \u003Ccode>v-for\u003C\u002Fcode>.","This is unrelated to the index-as-key mistake — it happened even with a correct, stable \u003Ccode>:key\u003C\u002Fcode>.","If you maintain a directive that does real cleanup, confirm your Vue version is \u003Ccode>3.5.41\u003C\u002Fcode> or later, and write a test that actually removes a list item rather than only mounting one.",{"id":379,"html":380,"type":138},"b62","\u003Cp>The listener nobody was tracking is the quiet failure mode that makes this bug worth knowing even now that it&#39;s fixed: it never crashed, never logged a warning, and would have kept accumulating in a long-running single-page app for as long as the affected Vue version stayed pinned. Compiler-level bugs like this are rare, but they&#39;re exactly why &quot;it works when I click it once&quot; isn&#39;t the same as &quot;it works&quot; — the loop is where cleanup earns its keep.\u003C\u002Fp>",{"id":382,"html":383,"type":138},"b63","\u003Cp>Have you run into a directive that seemed to stop working only inside a list, or found another case where \u003Ccode>NEED_PATCH\u003C\u002Fcode> mattered? Drop it in the comments.\u003C\u002Fp>",{"id":385,"html":386,"type":138},"b64","\u003C!-- related:start -->",{"id":388,"html":389,"text":389,"type":143,"level":31},"b65","📚 Read next",{"id":391,"type":149,"items":392,"ordered":18},"b66",[393,394,395],"\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-computed-caching-invalidation\">Vue Computed: What It Caches and When It Reruns\u003C\u002Fa>","\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fvue-weekly-definemodel-v-model-contract\">Vue defineModel: The v-model Contract, Explained\u003C\u002Fa>",{"id":397,"html":398,"type":138},"b67","\u003C!-- related:end -->",{"id":400,"type":401},"b68","divider",{"id":403,"html":404,"type":138},"b69","\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":406,"html":407,"type":138},"b70","\u003Cp>\u003Cem>Thanks for reading! Let&#39;s stay connected:\u003C\u002Fem>\u003C\u002Fp>",{"id":409,"type":149,"items":410,"ordered":18},"b71",[411,412,413],"⭐ \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>","You write a custom directive once — `v-tooltip`, `v-click-outside`, `v-ripple` — attach something in `mounted()`, tear it down in `unmounted()`, and trust the pair like you'd trust `addEventListener`\u002F`removeEventListener`. Then you drop it onto a row inside a filterable `v-for` list. Filter an item out. No error. No warning. The row disappears from the screen — and on some Vue 3 versions, `unmounted()` never ran. The listener it attached is still live, attached to a DOM node nothing references anymore. This was a real bug in Vue's own compiler, open for years, fixed only in Vue 3.5.41 — and understanding *why* it happened teaches you more about how `v-for` actually works than the fix note ever will.\n\n## What You'll Learn\n\nBy the end of this article you'll be able to:\n\n- Explain why a custom directive's lifecycle hooks can be skipped for an element inside `v-for`, even though the same directive works fine outside a loop\n- Reproduce the symptom, confirm whether your installed Vue version is affected, and verify the fix\n- Describe what a compiler patch flag is and why `NEED_PATCH` specifically matters for refs, vnode hooks, and custom directives\n- Write directive-based cleanup you can trust inside a list, instead of discovering the gap in production\n- Tell this bug apart from unrelated `v-for` gotchas (duplicate keys, index-as-key) that look similar but have different causes\n\n**Who this is for:** you've written a custom directive with `app.directive()` or a local `directives` option, and you've used `v-for` with `:key`. If directives themselves are new to you, the [Vue Guide to Custom Directives](https:\u002F\u002Fvuejs.org\u002Fguide\u002Freusable\u002Fcustom-directives.html) is worth reading first — this article assumes you know what `mounted`\u002F`unmounted` hooks are, not how to write your first one.\n\n## Table of Contents\n\n- [The Problem: A Cleanup Hook That Sometimes Doesn't Run](#the-problem-a-cleanup-hook-that-sometimes-doesnt-run)\n- [The Mental Model: Patch Flags Decide What the Runtime Bothers to Visit](#the-mental-model-patch-flags-decide-what-the-runtime-bothers-to-visit)\n- [The Bug, the Issue, and the Fix](#the-bug-the-issue-and-the-fix)\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.x** (verified 3.5.43, current on npm's `latest` tag as of this writing). Vue 3.6 is still a release candidate (`3.6.0-rc.10` on the `rc` tag) and isn't stable yet, so nothing here depends on it. The fix this article covers shipped in **Vue 3.5.41**, released August 5, 2026 — if your `package.json` pins anything older, you are likely affected (one of the two issues the fix closed was filed against 3.2.47, so this isn't a 3.5-only gap).\n\n## The Problem: A Cleanup Hook That Sometimes Doesn't Run\n\nSay you've written a small directive that logs its own lifecycle — a stand-in for anything real a directive might do (attach a listener, instantiate a third-party widget, start an observer):\n\n```vue\n\u003C!-- the directive — nothing unusual about it -->\n\u003Cscript setup>\nconst vLog = {\n  mounted(el, binding) {\n    console.log(`mounted: row ${binding.value}`)\n  },\n  unmounted(el, binding) {\n    console.log(`unmounted: row ${binding.value}`)\n    \u002F\u002F in real code: removeEventListener, destroy a tooltip instance,\n    \u002F\u002F disconnect an observer — whatever mounted() set up\n  },\n}\n\u003C\u002Fscript>\n```\n\nUsed on a single element, it behaves exactly as documented: one `mounted` log when it appears, one `unmounted` log when it's removed. Now put the same directive on an element inside a keyed `v-for`, and remove an item from the middle of the list by filtering the underlying array:\n\n```vue\n\u003Ctemplate>\n  \u003Cdiv v-for=\"row in visibleRows\" :key=\"row.id\" v-log=\"row.id\">\n    {{ row.label }}\n  \u003C\u002Fdiv>\n\u003C\u002Ftemplate>\n```\n\n```js\n\u002F\u002F remove row 2 from the data the list is built from\nvisibleRows.value = visibleRows.value.filter(r => r.id !== 2)\n```\n\nOn an affected Vue version, row 2's `\u003Cdiv>` disappears from the DOM exactly as expected — but the console only ever shows the `mounted` log for it, never `unmounted`. Nothing throws. Nothing warns. The directive's cleanup code simply never executes for that element, while the identical directive on a non-looped element works every time. That asymmetry — directive hooks are reliable alone, unreliable in a `v-for` — is the entire bug, and it's what makes it so easy to ship without noticing: your manual test on one element passes, and the loop case only misbehaves once a list gets edited in production.\n\n## The Mental Model: Patch Flags Decide What the Runtime Bothers to Visit\n\n**The mental model:** at compile time, Vue's template compiler inspects every element and decides which ones are worth revisiting when the component re-renders. An element with no dynamic text, no bound class, no bound prop — nothing that could ever change — gets compiled as fully static, and the runtime skips it entirely on every future patch. That skip is most of why Vue is fast: it never diffs what it already knows can't change.\n\nTo make that work, the compiler attaches a **patch flag** to every dynamic element — a small integer bitmask recording *which* kind of dynamic content it has: `TEXT` for interpolated text, `CLASS`\u002F`STYLE`\u002F`PROPS` for bound attributes, and so on. The runtime reads the flag instead of diffing blindly, so it only compares the parts of the vnode the flag says can change.\n\nNow consider an element that has no dynamic text or props at all — only a `ref`, a `v-bind` to a vnode hook, or (as in the example above) a custom directive. Nothing about its *rendered output* ever changes, but Vue still needs to **visit** it on every patch, because a `ref` needs updating, a vnode hook needs calling, or a directive's `updated`\u002F`unmounted` hook needs invoking at the right moment. That's exactly what the `NEED_PATCH` flag exists for: it tells the runtime \"nothing here looks different, but visit it anyway — something non-visual depends on it.\"\n\nThe bug was that **`NEED_PATCH` could get dropped specifically when that same element was also inside a `v-for`.** The compiler's own `v-for`-specific optimizations and its patch-flag analysis for refs\u002Fhooks\u002Fdirectives didn't compose correctly, so an element that would have correctly carried `NEED_PATCH` on its own lost that flag once it was also a loop child. Without the flag, the runtime had no signal telling it to visit the element during a patch — so the directive's lifecycle hook for that specific element silently went uninvoked.\n\n## The Bug, the Issue, and the Fix\n\nThis wasn't a hunch — it's two tracked issues and a merged fix in Vue's own repository. [Issue #12569](https:\u002F\u002Fgithub.com\u002Fvuejs\u002Fcore\u002Fissues\u002F12569), titled \"Custom directive's unmounted event is not triggered on v-for element,\" reported exactly the symptom above. [Issue #9239](https:\u002F\u002Fgithub.com\u002Fvuejs\u002Fcore\u002Fissues\u002F9239) reported the same underlying gap for `ref` callbacks on `v-for` children — the flag loss wasn't directive-specific, it affected every `NEED_PATCH`-only case: refs, vnode hooks, and custom directives alike.\n\nThe maintainer who fixed it, in [PR #11682](https:\u002F\u002Fgithub.com\u002Fvuejs\u002Fcore\u002Fpull\u002F11682) (\"preserve vnode lifecycle in stable v-for\"), described the bug precisely: \"When an element uses `ref`, `vnodeHook`, or custom directives, the `patchFlag` should be `NEED_PATCH`. However, when they are used together with `v-for`, the `patchFlag` gets lost.\" The fix tracks whether an element needs patching separately from whether it needs to stay a full \"block\" in the compiler's internal tree, and the `v-for` transform now adds `NEED_PATCH` back wherever one of these lifecycle-sensitive features is present. It shipped in **Vue 3.5.41** (2026-08-05).\n\nWhat that means for you in practice: if your `package.json` resolves `vue` to `3.5.41` or later — or anything on the current `3.5.43` — this is already fixed, and the example above behaves correctly: both `mounted` and `unmounted` fire for every row, every time. If you're pinned to an older `3.5.x` release, or anywhere earlier in the Vue 3 line (one of the two issues this fix closed, #9239, was reported against 3.2.47), the gap is real and silent. The straightforward fix is upgrading to 3.5.41 or later; this article doesn't catalog a non-upgrade workaround, since the bug lives in the compiler's static analysis rather than in anything your component code controls — check the linked GitHub issues directly if you need to stay pinned for now.\n\n\u003C!-- playground:start -->\n\n## 🎮 Try it yourself\n\n**[▶️ Open the interactive playground →](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fvue-weekly-vfor-directive-unmounted-hook\u002Fplayground)**\n\n_Runs right in your browser — poke at it and watch the concept react live._\n\n\u003C!-- playground:end -->\n\n## Edge Cases and Gotchas\n\n**This is not the index-as-key bug.** A separate, much more famous `v-for` mistake — using the array index as `:key` — causes Vue to *reuse* the wrong DOM node and misattribute state across reorders. That's a key-strategy problem you control by choosing a stable `:key`. The bug in this article happens even with a perfectly correct, stable `:key` — it's a compiler flag that was lost, not a key you chose wrong.\n\n**`v-if` and `v-for` on different elements of the same loop are unaffected.** This specifically concerned \"stable\" `v-for` — a keyed loop with no structural directive (`v-if`\u002F`v-for`) on the same element changing the number of children per iteration independent of the list itself. Combining `v-for` with `v-if` on the same tag is already discouraged by Vue's own style guide, and can throw a runtime reference error if the `v-if` expression depends on the `v-for`'s loop variable — a separate, unrelated problem, not this bug.\n\n**Built-in reactive bindings were never affected.** `v-model`, `v-show`, bound `:class`\u002F`:style`\u002Fprops, and interpolated text all carry their own patch flags (`PROPS`, `STYLE`, `CLASS`, `TEXT`) regardless of `v-for`. The gap was specific to elements whose *only* dynamic feature was a `ref`, a vnode hook, or a custom directive, with nothing else forcing a patch flag already.\n\n**`ref` callbacks silently missing inside `v-for` was the same bug.** If you've ever seen a `ref` array (`:ref=\"(el) => refs.push(el)\"`) inside a loop skip an entry or hold a stale element after a list changed, issue #9239 describes the identical mechanism — not a separate problem to debug.\n\n**Custom directives using only `created`\u002F`beforeMount`\u002F`beforeUpdate` hooks were affected identically to `mounted`\u002F`unmounted`.** The bug was about the patch flag being present at all, not about which specific directive hook fires — every hook on the affected element went uninvoked on the patches where the flag was missing.\n\n## Best Practices\n\n**Reach for this knowledge when** you maintain any directive that does real cleanup — removing listeners, destroying a third-party widget instance, disconnecting a `ResizeObserver` or `MutationObserver` — and that directive is ever used inside a list. Confirm your installed Vue is `3.5.41` or later before trusting that cleanup unconditionally.\n\n**Avoid relying on directive lifecycle hooks for anything correctness-critical** even after upgrading. Directives are the right tool for DOM-only side effects (focus management, third-party widget wiring, scroll\u002Fintersection observers) — not for application state that must be perfectly consistent, where a `watch` or computed-driven reactive update gives you a mechanism entirely outside the compiler's patch-flag analysis.\n\n**Write a real regression test for list-cleanup directives**, not just a manual click-through: mount a list with the directive, remove an item, and assert the directive's `unmounted` hook actually ran for it. This bug shipped silently for years precisely because it only reproduces once an item leaves a loop — a test that only ever mounts, never removes, would never have caught it.\n\n**Pin and read your Vue version deliberately**, especially on a long-lived app. `npm ls vue` (or checking the lockfile) tells you exactly what you're running; `npm view vue dist-tags` tells you what's current. A multi-year-old pin anywhere before `3.5.41` is exactly the situation this bug could still be lurking in.\n\n## FAQ\n\n### Why didn't Vue throw an error or a dev-mode warning for this?\n\nBecause nothing about the element's own rendering was wrong — the DOM node itself unmounted correctly along with its parent list item. The only thing missing was Vue's internal signal to also invoke the directive's hook on that specific vnode. There was no incorrect state for Vue to detect and warn about; the gap was an omission in the compiler's static analysis, not a runtime contradiction it could catch.\n\n### Does this affect `v-model` or other built-in directives?\n\nNo. `v-model` compiles to a bound prop plus an event listener, both of which already carry their own patch flags (`PROPS`) independent of this bug. The gap only touched elements whose *sole* dynamic feature was a `ref`, a vnode hook, or a **custom** directive with nothing else forcing a patch flag.\n\n### Is Nuxt affected the same way?\n\nYes, to the extent any given Nuxt app's resolved `vue` dependency was on an affected version — Nuxt renders on top of Vue's own core, so this is a Vue-core fix, not something Nuxt layers on. Check `vue`'s resolved version in your Nuxt project's lockfile the same way you would in a plain Vue app.\n\n### Is this fixed in Vue 2?\n\nNot applicable — Vue 2's compiler doesn't use this patch-flag\u002Fblock system at all (it was introduced with Vue 3's rewritten renderer), so the bug and its fix are both Vue-3-specific.\n\n### How do I confirm my own app isn't exposed?\n\nCheck your resolved `vue` version (`npm ls vue`), and if it's below `3.5.41`, reproduce the repro in this article in a scratch component before deciding whether to upgrade immediately or schedule it — a directive that only logs, with no real side effect, costs nothing to leave broken; one that owns a listener or a third-party instance is worth fixing promptly.\n\n\u003C!-- quiz:start -->\n\n## 🧠 Test yourself\n\nThink it clicked? **[Take the 9-question quiz →](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fvue-weekly-vfor-directive-unmounted-hook\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\n## Cheat Sheet\n\n| Task | What to check | Notes |\n| --- | --- | --- |\n| Confirm you're affected | `npm ls vue` | Affected: any Vue 3 release before `3.5.41` (reported as far back as 3.2.47) |\n| Confirm you're fixed | Vue `≥ 3.5.41` (current: `3.5.43`) | No flag, no config — the fix is purely internal |\n| Reproduce the symptom | Custom directive + keyed `v-for` + remove an item | Log `mounted`\u002F`unmounted`; on affected versions only `mounted` appears |\n| Tell it apart from index-as-key bugs | Does it happen with a correct, stable `:key`? | This bug happens even with a correct key; index-as-key is a separate mistake |\n| Root cause, one line | `NEED_PATCH` patch flag dropped for `ref`\u002Fvnode-hook\u002Fdirective-only elements inside `v-for` | [vuejs\u002Fcore#11682](https:\u002F\u002Fgithub.com\u002Fvuejs\u002Fcore\u002Fpull\u002F11682) |\n| What's NOT affected | `v-model`, bound `:class`\u002F`:style`\u002Fprops, interpolated text | These already carry their own patch flags regardless of `v-for` |\n\n```js\n\u002F\u002F A directive you can trust inside v-for, once Vue is ≥ 3.5.41:\nconst vCleanupCheck = {\n  mounted(el, binding) {\n    el.__cleanup = () => console.log(`cleaned up row ${binding.value}`)\n  },\n  unmounted(el, binding) {\n    el.__cleanup?.()\n  },\n}\n\u002F\u002F Regression-test it: mount a list, remove an item, assert unmounted ran.\n```\n\n## Key Takeaways\n\n- A custom directive's `mounted`\u002F`unmounted` hooks could silently fail to fire for an element inside a keyed `v-for`, with no error or warning — fixed in Vue 3.5.41.\n- The cause was a compiler detail: elements whose only dynamic feature is a `ref`, a vnode hook, or a custom directive need the `NEED_PATCH` flag to be visited during a patch, and that flag could get lost specifically when combined with `v-for`.\n- This is unrelated to the index-as-key mistake — it happened even with a correct, stable `:key`.\n- If you maintain a directive that does real cleanup, confirm your Vue version is `3.5.41` or later, and write a test that actually removes a list item rather than only mounting one.\n\nThe listener nobody was tracking is the quiet failure mode that makes this bug worth knowing even now that it's fixed: it never crashed, never logged a warning, and would have kept accumulating in a long-running single-page app for as long as the affected Vue version stayed pinned. Compiler-level bugs like this are rare, but they're exactly why \"it works when I click it once\" isn't the same as \"it works\" — the loop is where cleanup earns its keep.\n\nHave you run into a directive that seemed to stop working only inside a list, or found another case where `NEED_PATCH` mattered? Drop it in the comments.\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 Computed: What It Caches and When It Reruns](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fvue-weekly-computed-caching-invalidation)\n- [Vue defineModel: The v-model Contract, Explained](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fvue-weekly-definemodel-v-model-contract)\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":416,"description":111},"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fvue-weekly-vfor-directive-unmounted-hook","01a11b9f-f19f-7057-9a83-d5b8bc6624ab",{"name":419,"part":420,"total":420,"items":421},"Vue Deep Dive",8,[422,426,430,434,439,443,448,452],{"slug":423,"title":424,"publishedAt":425,"readingMinutes":96},"vue-weekly-reactivity-ref-vs-reactive","Vue Reactivity Explained: ref vs reactive (+ Cheat Sheet)","2026-08-24T13:57:38.080Z",{"slug":427,"title":428,"publishedAt":429,"readingMinutes":103},"vue-weekly-nexttick-batching-dom-updates","Vue nextTick Explained: How DOM Updates Are Batched","2026-09-14T12:19:00.606Z",{"slug":431,"title":432,"publishedAt":433,"readingMinutes":103},"vue-weekly-composables-shared-state-trap","Vue Composables: The Shared State Trap (+ Cheat Sheet)","2026-09-22T19:37:11.460Z",{"slug":435,"title":436,"publishedAt":437,"readingMinutes":438},"vue-weekly-computed-caching-invalidation","Vue computed(): What It Caches and When It Reruns","2026-09-30T12:10:53.362Z",12,{"slug":440,"title":441,"publishedAt":442,"readingMinutes":103},"vue-weekly-definemodel-v-model-contract","Vue defineModel: The v-model Contract, Explained","2026-10-05T06:11:02.623Z",{"slug":444,"title":445,"publishedAt":446,"readingMinutes":447},"vue-weekly-watch-vs-watcheffect","Vue watch vs watchEffect: The Complete Guide","2026-10-12T06:10:54.504Z",15,{"slug":449,"title":450,"publishedAt":451,"readingMinutes":447},"vue-weekly-reactivity-batch-watcher-throws","Vue 3.6: When a Watcher Throws, Your Other Watchers Stall","2026-10-19T06:10:59.563Z",{"slug":46,"title":110,"publishedAt":113,"readingMinutes":103},{"id":454,"locked":18},"01a11b9f-f1c7-71a3-af27-2368a9fd3085",[456],{"id":45,"slug":46,"title":48,"_count":457},{"questions":51},[459],{"locale":13,"slug":46},{"id":45,"slug":46,"title":48,"_count":461,"questionCount":51},{"questions":51},[]]