skip to content

A Vue 3 order-details tab wrapped in `<KeepAlive>` shows outdated data after switching back and keeps polling while hidden; why, and how do you fix it?

level: seniorimportance: should knowfreq 38%

answer

  1. setup ran exactly once
  2. the instance is alive while hidden
  3. cache key is the component type
  4. refresh on show, pause on hide

basics

~20 s

Vue's KeepAlive reuses the instance, so data loaded in setup or onMounted is never refetched, and its timers and watchers keep running while hidden. Refetch in onActivated, pause in onDeactivated, and key or evict entries whose identity changed.

solid answer

~40 s

A cached instance keeps whatever it loaded, because `setup()` and `onMounted` run only on the first visit; returning just reinserts the old DOM. Deactivation also does not stop the instance's effects, so an interval or watcher started in `setup()` keeps firing for a tab nobody sees. The fixes: refetch or revalidate in `onActivated` (which also covers the first visit), stop polling in `onDeactivated`, and make identity part of the cache. `<KeepAlive>` keys its cache by the child vnode's `key`, falling back to the component type, so one `OrderDetails` instance otherwise serves every order; giving it `:key="orderId"` caches one instance per order, which then needs `max` to bound memory. To discard a cached view outright, drop its name from a reactive `include`.

code

vue · 12 lines
vue
<script setup lang="ts">
import { ref } from 'vue'
import OrderDetails from './OrderDetails.vue'

const activeOrderId = ref('A-1001')
</script>

<template>
  <KeepAlive :max="10">
    <OrderDetails :key="activeOrderId" :order-id="activeOrderId" />
  </KeepAlive>
</template>

go deeper

for a junior

Know that a cached component does not reload its data when shown again, because it is the same instance.

for a middle

Explain that setup and onMounted run once, that deactivated instances keep their effects running, and that the cache is keyed by key or component type.

for a senior

Fix the whole scenario: refresh in onActivated, pause in onDeactivated, key by entity with a max, and evict through a reactive include list.

for a principal

Separate user-owned view state, which KeepAlive preserves well, from server-owned data, which needs a freshness policy outside the component.

## Symptoms A support app shows orders in tabs, with the active one rendered through `<KeepAlive>` so that notes typed into a tab survive switching. Two bug reports arrive: 1. After switching away and back, an order's status still shows **"pending"** although it shipped minutes ago. 2. The network panel shows `/api/orders/…` being polled every ten seconds **for tabs that are not visible**. Both come from the same fact: a cached instance is the **same, still-live** instance. ## Why the data is stale - `setup()` and `onMounted` run once per instance. If the order is fetched there, it is fetched on the first visit only. - On return, `<KeepAlive>` moves the existing DOM back into the page and fires `onActivated`. Nothing re-runs the fetch unless you put it in `onActivated`. - The cache has no notion of time or freshness; it holds the instance until it is evicted by `max`, pruned by `include`/`exclude`, or the `<KeepAlive>` itself unmounts. ## Why it keeps polling Deactivation moves the component's DOM into a detached container and runs `onDeactivated`, but it **does not stop the instance's reactive effects**. A `setInterval` started in `setup()`, a `watch` on a shared store or a websocket subscription all keep going. The component can even keep re-rendering into its detached DOM. With many cached tabs this multiplies network traffic and CPU for content nobody sees. ## The wrong identity problem `<KeepAlive>` keys each cache entry by the child vnode's **`key`**, and when there is none, by the **component type**. If `<OrderDetails :order-id="id" />` is rendered for every order, all orders share **one** cached instance; switching orders just updates the prop, and anything derived once from the first `orderId` (a fetch in `setup()`, a local draft) is wrong for the next order. | Keying | Cache entries | Consequence | |---|---|---| | No `key` | One per component type | One instance reused across all orders; prop changes must be watched | | `:key="orderId"` | One per order | Each order keeps its own state; memory grows with orders visited | | `:key="orderId"` plus `:max="10"` | At most 10 | Least recently accessed order is destroyed beyond 10 | ## The fix, step by step 1. **Refresh on show.** Move the load into `onActivated`. It runs on the first mount too, so do not also call it from `onMounted`. Optionally skip the request if the data is younger than a threshold. 2. **Pause on hide.** Start polling in `onActivated` and clear it in `onDeactivated`. The same applies to subscriptions and `window` listeners. 3. **Choose the identity.** If each order should keep its own notes, render with `:key="orderId"`; if one instance should follow the current order, `watch` the `orderId` prop and reload when it changes. 4. **Bound the cache.** With per-order keys, set `max` so memory cannot grow with every order an agent opens. 5. **Evict deliberately.** Closing an order's tab does not by itself evict its cached instance; it stays in the cache until `max` pushes it out. To destroy cached views on demand, remove the component name from a reactive `include` list, keeping in mind that this evicts every cached instance with that name. ```vue <script setup lang="ts"> import { ref, onActivated, onDeactivated } from 'vue' const props = defineProps<{ orderId: string }>() const order = ref<{ status: string } | null>(null) let poll: ReturnType<typeof setInterval> | undefined const load = async () => { order.value = await fetch(`/api/orders/${props.orderId}`).then((r) => r.json()) } onActivated(() => { load(); poll = setInterval(load, 10_000) }) onDeactivated(() => clearInterval(poll)) </script> ``` ## Judging the trade-off - KeepAlive is worth it for **expensive-to-rebuild, user-owned state**: drafts, scroll inside a panel, a wizard's progress. - It is a poor fit for **server-owned data** that must be current; for that, cache the data in a shared layer with its own freshness rules and let the component be disposable. - Every cached instance holds its DOM, reactive state and closures. Measure heap usage after visiting many tabs and choose `max` accordingly. ## Summary KeepAlive preserves an instance, not a snapshot with a freshness policy. Stale data and background work are the direct consequences, fixed by refreshing in `onActivated`, pausing in `onDeactivated`, keying by the real identity and bounding or evicting the cache.

  • Why does one cached `OrderDetails` show the first order's draft when you switch to a second order?
    Without a `key`, `<KeepAlive>` caches by component type, so both orders reuse the same instance and only the `orderId` prop changes. State initialised once in `setup()` belongs to the first order. Give the child `:key="orderId"` to cache one instance per order, or watch the prop and reset state when it changes.
  • How can a cached view be thrown away on demand, for example after an order is closed?
    Bind `include` to a reactive array of component names and remove the view's name; `<KeepAlive>` prunes entries that no longer match after the next render, destroying them. The filter is by name, so it evicts every cached instance of that component, including other orders keyed separately. Merely no longer rendering a keyed child does not evict it: it stays cached until `max` or an `include`/`exclude` change removes it.

saying these in an interview costs you the question

  • KeepAlive automatically refetches data when a cached component is shown
  • Deactivated components pause their watchers and timers automatically
  • KeepAlive caches a separate instance per prop value without a key
  • Putting the fetch in onMounted keeps cached data fresh
  • The only way to clear a KeepAlive cache is reloading the page