Cache the Instance, Skip the Remount: What Actually Survives a Swap
From dynamic components you know the default cost of a swap: the old instance unmounts, a new one mounts, local state resets, onMounted re-runs, scroll position is gone. keep-alive is the built-in that changes that contract — it caches deactivated instances instead of destroying them:
<keep-alive>
<component :is="activeTab" />
</keep-alive>
When activeTab changes, the previous component's instance is preserved in the cache: its reactive state, its DOM element (detached, not discarded), its mounted-component subtree, and its pending non-DOM resources all stay as they were. Switching back re-attaches that same instance — no remount, no data refetch, the half-typed form and the scroll position intact. From the component's perspective, it was briefly hidden and returned; from Vue's perspective, one instance was reused for the lifetime of the cache.
The mechanics around the cache, in the order they bite:
- One dynamic child.
keep-alivetakes a single child; the<component :is>outlet (or a singlev-if/v-elsechain) is the canonical target. Wrapping av-forof items caches nothing useful — the cache keys on the rendered type, not each list entry. - Keys create separate cache entries.
<keep-alive><component :is="EditorTab" :key="docId" /></keep-alive>maintains one cached instance perdocId. Cache identity is type + key, so:keymultiplies cache entries — powerful (ten documents open at once) and dangerous (ten documents' state held forever) in the same feature. - Cache hits still mount once.
onMountedfires on the first activation only; subsequent returns fire the activated hook instead. Distinguishing the two is the subject of the third chapter. - Eviction destroys. When the cache exceeds
max, or the parent itself unmounts, cached instances are truly unmounted —onUnmountedruns, and any resources they held are released. The cache delays destruction; it does not abolish it.
The other half of the picture is what keeps running while deactivated. Detaching the DOM does not stop the component's reactive effects: its watch and watchEffect callbacks still fire against its state, network requests still resolve into it, setInterval still ticks. For a tab whose watchers only touch local state, that is invisible. For a tab polling an endpoint every ten seconds or subscribed to a websocket, the deactivated component keeps making requests in the background — the app appears to cache itself into a battery and bandwidth problem. Moving that background work under control of the activation hooks is the pattern the next chapter formalizes.
keep-alive also reaches beyond dynamic outlets: wrapping a single <router-view> inside it caches route components by their declared names — the standard recipe for tab-like pages that live behind URLs — with the same include, exclude, and max controls applying unchanged. And keep-alive does not make state correct, only persistent: a cached list renders yesterday's data with no timestamp in sight, so cached components take on a freshness obligation — refresh in onActivated — that freshly mounted components never have. Persistence without invalidation is precisely how cached screens become the most stale-prone views in an application, and it is a bug review must look for, not a feature.
The design question keep-alive forces you to answer is a memory-vs-recompute trade: every cached instance is memory held for the chance to avoid a remount's cost. A dashboard tab that fetches three endpoints and renders a chart is a strong cache candidate — its remount is seconds of loading on every visit. A rarely used settings panel with a two-line form is a weak one — its remount is imperceptible, and caching it is memory spent for nothing. The scoping tools — include, exclude, and max — exist so the decision can be made per component rather than per application.
include, exclude, max: Naming What the Cache Is Allowed to Hold
An uncritical <keep-alive> wraps whatever child renders and holds every instance it has seen — on a dashboard with a dozen tab components, some heavy, most trivial, that is memory growing monotonically as the user explores. Scoping the cache is how you spend the memory only where a remount actually hurts.
include and exclude match by component name — strings, arrays, or a RegExp:
<keep-alive include="DashboardTab, ChartPanel">
<component :is="activeTab" />
</keep-alive>
<keep-alive :exclude="[/Settings/, 'SimpleForm']">
<component :is="activeTab" />
</keep-alive>
The matching subject is the component's name, and here is where script-setup apps stumble: the name is inferred from the filename (DashboardTab.vue → DashboardTab), and it can be overridden with defineOptions({ name: ... }). So include="DashboardTab" works only if the file is named — or explicitly named — exactly that. A refactor that renames DashboardTab.vue to TabDashboard.vue silently stops the caching; nothing warns, the state-loss bug simply returns. When caching is load-bearing for a component's UX, pin the name with defineOptions so the filename can change without breaking the cache contract. include and exclude on the same element is invalid — Vue takes include and warns.
max caps the number of cached instances with least-recently-used eviction:
<keep-alive :max="5">
<component :is="activeTab" :key="docId" />
</keep-alive>
When a sixth instance is activated, the oldest cached one is evicted — truly unmounted, its state destroyed. max converts "grow the cache forever" into a bounded memory budget with defined, predictable worst-case behavior. Combine it with keyed entries and you get a document-editor cache: five most-recent documents instant, older ones reloading on demand — the same LRU contract as a browser's tab discard, and it should be presented in review exactly that way.
The selection criterion for the cache — which components to name in include — is a measurement-shaped judgment:
| Strong candidate | Weak candidate |
|---|---|
| Mount runs multiple API fetches | Mount is local-only, no I/O |
| Renders heavy trees (charts, big tables, maps) | Renders a small form or card |
| Return visits preserve work (editors, filters, scroll) | Return visits start fresh anyway (marketing panels) |
| User flips between it and siblings frequently | Rarely visited (buried settings) |
The matching rules have sharp edges worth knowing before you debug them. Strings and arrays match exact component names (the string form is comma-parsed: include="ChartPanel, DataGrid"), while a RegExp matches by substring: :include="[/List/]" catches UserList, TodoList, and BlacklistView alike — anchor the pattern deliberately or expect company. The matched subject is the component's name: for script-setup SFCs that is filename-inferred by the SFC compiler and overridable only via defineOptions, which is why a load-bearing cache pins the name instead of trusting the file's spelling forever. And specifying both filters triggers a dev warning that states the priority outright:
[Vue warn] Specified prop "include" and "exclude" at the same time.
"include" has a higher priority than "exclude".
Include whitelists first; exclude can only subtract from what survives — useful to know when a component you expected to be excluded is still being cached.
A common architecture pattern falls out: the cache policy belongs at the container. The tab host decides what is worth caching (include of the heavy panels), each tab component stays unaware of the policy, and the policy changes in one place when profiling says so. What must not stay unaware is anything doing background work — a cached component's timers and subscriptions keep running while deactivated, and the hook pair that owns that lifecycle is next.
activated and deactivated: The Hook Pair — and the Timer That Keeps Ticking
A cached component's lifecycle no longer maps to mount/unmount. Vue provides a parallel pair — onActivated and onDeactivated — and knowing exactly when each fires is the difference between a correct cache user and a haunted one.
The timeline, for one cached component across a full session:
first render → onMounted + onActivated (activation hooks fire on the FIRST mount too)
switched away → onDeactivated (NOT onUnmounted — the instance is cached)
switched back → onActivated (NOT onMounted — no remount happened)
evicted (max) or parent unmount → onUnmounted (the real destruction, at the end)
<script setup>
import { onMounted, onActivated, onDeactivated } from 'vue'
onMounted(() => console.log('first mount'))
onActivated(() => console.log('visible — including the very first time'))
onDeactivated(() => console.log('cached — DOM detached, state kept'))
onUnmounted(() => console.log('evicted or torn down'))
</script>
The onActivated-on-first-mount behavior is the feature that makes the hooks safe for symmetric setup: you can start work in onActivated and stop it in onDeactivated without a special case for the initial render.
The pitfall the hooks exist to fix. Deactivation detaches DOM; it does not pause effects. A dashboard tab with:
watchEffect(async () => {
stats.value = await fetchStats() // re-runs whenever its deps change
})
setInterval(() => pollMetrics(), 10_000) // ticks forever, cached or not
carries both of those into the cache: the interval keeps polling every ten seconds while the tab is invisible, and any shared reactive dependency keeps re-triggering the effect. Three components cached like this turn tab-switching into background load nobody scheduled. The fixes, in order of preference:
1. Gate polling on visibility — the simplest correct form:
let timer
onActivated(() => { timer = setInterval(pollMetrics, 10_000) })
onDeactivated(() => clearInterval(timer))
onUnmounted(() => clearInterval(timer)) // eviction path must also clean up
2. Stop the watcher with the cache. Create the watchEffect inside onActivated and hold its stop handle for onDeactivated:
let stop
onActivated(() => { stop = watchEffect(load) })
onDeactivated(() => stop?.())
The onDeactivated+onUnmounted pair for cleanup mirrors why async work needs both paths — eviction destroys without deactivating first, so resource release belongs in both hooks (or in a scope that dies with the instance).
3. Architect out of the problem — move polling to a store-level singleton or a composable owned by the app shell, not by any tab. One interval serving every consumer is immune to caching decisions entirely, and it is often the right answer: the data is shared, so its lifecycle should not be hostage to one component's cache state.
For copy-paste completeness, the full gating pattern — eviction path included:
let timer
let stopEffect
onActivated(() => {
timer = setInterval(poll, 10_000)
stopEffect = watchEffect(loadRecent)
})
onDeactivated(() => {
clearInterval(timer)
stopEffect && stopEffect()
})
onUnmounted(() => clearInterval(timer)) // eviction never fires onDeactivated
That last line is the one code review must demand: eviction via max — and parent teardown — destroy the instance without calling onDeactivated, so cleanup written only in the deactivated hook leaks the interval in exactly the scenario max was added to create. One cost the hooks cannot fix at all: the detached DOM stays in memory. A cached tab holding ten thousand list rows retains every node while hidden; when the payload is that heavy, virtualize the list or lift the data and keep the instance lean. Caching is not a substitute for a component that is cheap to exist.
A review checklist for any component entering the cache: what timers exist (all cleared on deactivate/unmount), what subscriptions exist (websocket/SSE/store listeners stopped), what the component does when its DOM reappears after an hour away (refresh stale data in onActivated — cached ≠ current), and whether its include name survives the filename. Get those four right and caching becomes what it promises: an instance held ready, with no ghosts running in the dark. Data that must be shared across cached and uncached components alike is a different problem — solved not by caching instances but by provide and inject.
Quiz
1. A component inside keep-alive is switched away and back. Which hooks fire, in order?
2. keep-alive include="DashboardTab" has no effect in a script-setup app; every tab is cached. Why?
3. A cached tab runs setInterval(poll, 10000) in setup. The user switches to another tab. What happens?
4. With <keep-alive :max="3"> and keyed children, the user opens a fourth document. What does Vue do?
Flashcards
Question
What does keep-alive preserve across a swap, and what is its default behavior?
Click to reveal answer
Answer
It caches the deactivated instance — reactive state, DOM element (detached), mounted subtree — so returning re-attaches the same instance with no remount. Uncritical wrapping caches every child; scope it with include/exclude/max. Keys create separate entries (identity = type + key).
Question
What do include, exclude, and max do, and what do they match by?
Click to reveal answer
Answer
include/exclude filter which components are cached BY COMPONENT NAME (string/array/RegExp; script-setup names are filename-inferred, overridable via defineOptions — a rename silently changes cache membership; including both props is invalid). max caps cached instances with least-recently-used eviction, bounding memory.
Question
Give the keep-alive lifecycle timeline for one cached component.
Click to reveal answer
Answer
First render: onMounted + onActivated (activation hooks fire on first mount too). Switch away: onDeactivated (not unmounted). Return: onActivated (not mounted). Eviction via max or parent teardown: onUnmounted — the only true destruction.
Question
Why do cached components keep causing 'background load', and the three fixes?
Click to reveal answer
Answer
Deactivation detaches DOM but effects keep running: intervals tick, watchEffects re-trigger, websockets push. Fix by (1) starting/stopping timers in onActivated/onDeactivated (+onUnmounted for eviction), (2) holding a watchEffect stop handle per activation, or (3) lifting shared polling to an app-shell singleton immune to tab caching.
Question
Which components deserve the cache, and what review checklist applies?
Click to reveal answer
Answer
Cache remount-expensive, frequently-returned-to components (multi-fetch dashboards, charts, editors, maps with view state); skip cheap forms and rare panels — every entry is memory held. Checklist: timers cleared on deactivate+unmount · subscriptions stopped · onActivated refreshes possibly-stale data (cached ≠ current) · include names pinned via defineOptions.
Revision Notes
Key Takeaways
- 1. keep-alive caches instances across swaps — state, DOM, and subtree survive; eviction via max or teardown is the only real unmount
- 2. include/exclude match by component name — filename-derived in script setup — and max bounds memory with LRU eviction
- 3. The activated/deactivated pair replaces mount/unmount for cached components, with onActivated firing on the first mount for symmetric setup
- 4. Deactivation pauses nothing but DOM: timers, watchers, and subscriptions keep running and must be gated by the hooks or lifted out
- 5. Every cache entry is memory for a future remount — spend it on expensive, frequently-visited panels, and refresh stale data on activation
Interview Tips
- • "What does keep-alive do to the lifecycle?" — activated/deactivated replace mounted/unmounted after the first activation; note onActivated fires on initial mount and onUnmounted waits for eviction/teardown
- • "Why is my cached tab still polling?" — deactivation detaches DOM but doesn't stop effects; show the onActivated/onDeactivated gating (plus the onUnmounted eviction path) or the singleton-lift
- • "How does keep-alive decide what to cache?" — name-based include/exclude (filename-inferred; defineOptions pins it) and max with LRU eviction — mention the silent rename pitfall
- • "When is keep-alive the wrong tool?" — cheap remounts, memory-constrained targets, or shared data that belongs in a store — cached state that's stale is a bug, not a feature
Cheat Sheet
keep-alive
<keep-alive include="DashboardTab, ChartPanel" :max="5">
<component :is="activeTab" :key="docId" /> <!-- one child; identity = type + key -->
</keep-alive>
Lifecycle: mount → onMounted + onActivated · switch away → onDeactivated · return → onActivated · evict/unmount-tree → onUnmounted.
Matching: include/exclude by component NAME — script-setup name = filename (pin with defineOptions when caching is load-bearing; renames silently change membership) · strings/arrays/RegExp · include+exclude together is invalid · max = LRU eviction, memory bounded.
The trap — deactivated ≠ stopped: timers tick, watchEffects fire, sockets push while cached. Gate them:
let timer, stop
onActivated(() => { timer = setInterval(poll, 10e3); stop = watchEffect(load) })
onDeactivated(() => { clearInterval(timer); stop?.() })
onUnmounted(() => clearInterval(timer)) // eviction path cleans up too
or lift shared polling to an app-shell singleton.
Cache policy: container owns include (one place to tune) · cache remount-expensive, frequently-visited panels (charts, editors, multi-fetch dashboards) · skip cheap/rare ones · cached ≠ current — refresh stale data in onActivated.