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.

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/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.
By the end of this article you'll be able to:
- 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 - Reproduce the symptom, confirm whether your installed Vue version is affected, and verify the fix
- Describe what a compiler patch flag is and why
NEED_PATCHspecifically 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
v-forgotchas (duplicate keys, index-as-key) that look similar but have different causes
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 is worth reading first — this article assumes you know what mounted/unmounted hooks are, not how to write your first one.
- The Problem: A Cleanup Hook That Sometimes Doesn't Run
- The Mental Model: Patch Flags Decide What the Runtime Bothers to Visit
- The Bug, the Issue, and the Fix
- Edge Cases and Gotchas
- Best Practices
- FAQ
- Cheat Sheet
- Key Takeaways
This 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).
Say 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):
Used 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:
On an affected Vue version, row 2's <div> 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.
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.
To 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/STYLE/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.
Now 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/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."
The 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/hooks/directives 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.
This wasn't a hunch — it's two tracked issues and a merged fix in Vue's own repository. Issue #12569, titled "Custom directive's unmounted event is not triggered on v-for element," reported exactly the symptom above. Issue #9239 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.
The maintainer who fixed it, in PR #11682 ("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).
What 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.
Runs right in your browser — poke at it and watch the concept react live.
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.
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/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.
Built-in reactive bindings were never affected. v-model, v-show, bound :class/:style/props, 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.
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.
Custom directives using only created/beforeMount/beforeUpdate hooks were affected identically to mounted/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.
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.
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/intersection 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.
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.
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.
Because 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.
No. 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.
Yes, 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.
Not applicable — Vue 2's compiler doesn't use this patch-flag/block system at all (it was introduced with Vue 3's rewritten renderer), so the bug and its fix are both Vue-3-specific.
Check 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.
Think it clicked? Take the 9-question quiz →
Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.
| Task | What to check | Notes |
|---|---|---|
| Confirm you're affected | npm ls vue | Affected: any Vue 3 release before 3.5.41 (reported as far back as 3.2.47) |
| Confirm you're fixed | Vue ≥ 3.5.41 (current: 3.5.43) | No flag, no config — the fix is purely internal |
| Reproduce the symptom | Custom directive + keyed v-for + remove an item | Log mounted/unmounted; on affected versions only mounted appears |
| 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 |
| Root cause, one line | NEED_PATCH patch flag dropped for ref/vnode-hook/directive-only elements inside v-for | vuejs/core#11682 |
| What's NOT affected | v-model, bound :class/:style/props, interpolated text | These already carry their own patch flags regardless of v-for |
- A custom directive's
mounted/unmountedhooks could silently fail to fire for an element inside a keyedv-for, with no error or warning — fixed in Vue 3.5.41. - The cause was a compiler detail: elements whose only dynamic feature is a
ref, a vnode hook, or a custom directive need theNEED_PATCHflag to be visited during a patch, and that flag could get lost specifically when combined withv-for. - This is unrelated to the index-as-key mistake — it happened even with a correct, stable
:key. - If you maintain a directive that does real cleanup, confirm your Vue version is
3.5.41or later, and write a test that actually removes a list item rather than only mounting one.
The 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.
Have 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.
- Vue Composables: The Shared State Trap (+ Cheat Sheet)
- Vue Computed: What It Caches and When It Reruns
- Vue defineModel: The v-model Contract, Explained
🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.
Thanks for reading! Let's stay connected:
- ⭐ GitHub — follow me and star the projects: github.com/parsajiravand
- 💬 Discord — join the frontend best-practices community: discord.gg/d9KRhuAwQ
- 📸 Instagram — frontend best practices, daily: @bestpractice___
Keep reading
One post a day, in your inbox
Each one with a runnable playground and a quiz. No pitch, no digest, unsubscribe in one click.
0 comments
Sign in to join the discussion, like comments, and save articles for later.