KeepAlive and Caching Components
Keep an expensive component's instance alive between route or tab changes, and run work in onActivated instead of onMounted.
<KeepAlive :include="['ReportsView']" :max="3"><component :is="Component" /></KeepAlive><KeepAlive> is a built-in component that caches the instance inside it instead of destroying it. Without it, switching away unmounts the component: its state, its fetched data and its half-filled form are gone, and returning pays for all of it again. With it, the instance stays in memory and its DOM is taken out of the page.
Choosing what to cache
Caching everything is a memory leak with a friendlier name. include and exclude narrow it, and both accept a comma-delimited string, a RegExp, or an array of either. They match the component's name, which a <script setup> component infers from its filename since Vue 3.2.34. max caps the number of cached instances and discards the least recently used one when the cap is reached.
Activated, not mounted
A cached component is deactivated rather than unmounted, so onMounted does not run again. onActivated runs on the first mount and on every return from the cache; onDeactivated runs when it goes into the cache and also on a real unmount. Timers, polling and subscriptions belong in that pair, not in onMounted and onUnmounted.
What trips people up
Cached state includes stale state: a dashboard restored from the cache shows numbers from ten minutes ago until something refreshes them. In templates <KeepAlive> and <keep-alive> are the same component, spelled two ways.
Example
<script setup lang="ts">
import { onActivated, onDeactivated, ref } from 'vue'
const price = ref<number | null>(null)
const failed = ref(false)
let timer: number | undefined
async function refresh(): Promise<void> {
try {
const response = await fetch('/api/price')
const data = (await response.json()) as { value: number }
price.value = data.value
failed.value = false
} catch {
failed.value = true
}
}
onActivated(() => {
void refresh()
timer = window.setInterval(() => void refresh(), 30000)
})
onDeactivated(() => {
window.clearInterval(timer)
})
</script>
<template>
<section>
<h2>Live price</h2>
<p v-if="failed">Could not load the price</p>
<p v-else-if="price === null">Loading</p>
<p v-else>{{ price.toFixed(2) }}</p>
</section>
</template>
<style scoped>
section {
padding: 1rem;
}
</style>When to use it
- A dashboard the user leaves and returns to several times a minute
- A multi-step form that must survive a navigation to a help page and back
- A map or chart whose setup costs hundreds of milliseconds on every mount
- Pausing a polling timer while a cached view is off screen
More examples
Caching route components through the RouterView slot
RouterView's scoped slot hands you the matched component as Component and the current route as route, so you can wrap the route component itself rather than the RouterView. Only the two named views are cached, and max keeps three instances at most. Reach for this when a view is expensive to rebuild — a dashboard with several requests, or a long form the user navigates away from and back to.
<script setup lang="ts">
import { RouterView } from 'vue-router'
</script>
<template>
<main>
<router-view v-slot="{ Component, route }">
<keep-alive :include="['DashboardView', 'ReportsView']" :max="3">
<component :is="Component" :key="route.path" />
</keep-alive>
</router-view>
</main>
</template>
<style scoped>
main {
padding: 1rem;
}
</style>Restoring scroll position on activation
KeepAlive preserves the component instance, but its elements are moved into a detached container while it is deactivated, and a detached element reports a scrollTop of 0. onDeactivated runs after that move, so the offset has to be recorded while the list is still on screen — here on every scroll event — and written back on activation, which gives the user the list where they left it. useTemplateRef arrived in Vue 3.5 and takes the ref name as a string; the older form of declaring a ref whose variable name matches the ref attribute still works.
<script setup lang="ts">
import { onActivated, ref, useTemplateRef } from 'vue'
const listEl = useTemplateRef<HTMLElement>('list')
const savedTop = ref(0)
function rememberScroll(): void {
savedTop.value = listEl.value?.scrollTop ?? 0
}
onActivated(() => {
if (listEl.value) {
listEl.value.scrollTop = savedTop.value
}
})
</script>
<template>
<ul ref="list" @scroll="rememberScroll">
<li v-for="n in 200" :key="n">Row {{ n }}</li>
</ul>
</template>
<style scoped>
ul {
max-height: 12rem;
overflow-y: auto;
}
</style>Tabs that keep one panel's state but not another's
exclude takes a plain string here, which is the comma-delimited form: 'SettingsTab' or 'SettingsTab,HelpTab' both work without v-bind. The report panel keeps its filters and loaded rows between tab switches; the settings panel is rebuilt each time, which is what you want for a form that should not show yesterday's draft. Both panels are matched by the name inferred from their filenames.
<script setup lang="ts">
import { ref, type Component } from 'vue'
import ReportTab from './ReportTab.vue'
import SettingsTab from './SettingsTab.vue'
const tabs: Record<string, Component> = {
ReportTab,
SettingsTab
}
const current = ref<keyof typeof tabs>('ReportTab')
</script>
<template>
<div>
<button
v-for="(_, name) in tabs"
:key="name"
type="button"
@click="current = name"
>
{{ name }}
</button>
<keep-alive exclude="SettingsTab">
<component :is="tabs[current]" />
</keep-alive>
</div>
</template>
<style scoped>
button {
margin-right: 0.5rem;
}
</style>
Discussion