skip to content

In Vue Router 5, clicking from /products/1 to /products/2 keeps showing product 1; why, and how do you fix the component?

level: middleimportance: must knowfreq 72%

answer

  1. same record, same component
  2. patched, not remounted
  3. setup and onMounted run once
  4. watch the param, immediately
  5. or remount with a key

basics

~10 s

Both URLs match the same record, so RouterView patches the existing component instead of remounting it; setup and onMounted do not run again. Watch route.params.id, or the prop from props: true, with immediate: true.

solid answer

~40 s

`/products/1` and `/products/2` match the same record, so `RouterView` renders the same component and Vue **reuses the instance**, patching it with the new route rather than destroying and recreating it. Anything done once in `setup` or `onMounted`, such as `fetchProduct(route.params.id)`, is not repeated, so the page keeps product 1. The fix is to make the load depend on the param: `watch(() => route.params.id, load, { immediate: true })`, or with `props: true` watch `props.id`. The router also offers the `onBeforeRouteUpdate` guard for this, and forcing a remount with a `:key` on the view component is the blunt alternative. Reuse is deliberate: it is cheaper than a remount and keeps local state and scroll-sensitive DOM intact.

code

vue · 17 lines
vue
<script setup lang="ts">
import { ref, watch } from 'vue'
import { fetchProduct, type Product } from '@/api/products'

const props = defineProps<{ id: string }>() // record has props: true
const product = ref<Product | null>(null)

watch(
  () => props.id,
  async (id) => {
    product.value = null
    const result = await fetchProduct(id)
    if (id === props.id) product.value = result // drop stale responses
  },
  { immediate: true },
)
</script>

go deeper

for a junior

Remember that the same component is reused when only params change, so code in onMounted does not rerun.

for a middle

Explain the patch-versus-remount decision and compare watching the param, watching a prop, the update guard and a keyed remount.

for a senior

Handle rapid navigation races, undefined params while leaving, and local state that must reset, and choose the fix per page.

for a principal

Set a team pattern for route-driven data loading so every detail page handles reuse, races and resets the same way.

## What happens on a params-only change When the user navigates from `/products/1` to `/products/2`, both URLs match the record `/products/:id`. `RouterView` renders the component of the matched record. Because the component is the **same** before and after, Vue's renderer patches the existing instance with the new route instead of unmounting it and mounting a new one. The router docs state it directly: the same component instance is reused, which is more efficient, and some lifecycle hooks are therefore not called. What does **not** run again on the reused instance: - `setup()` / `<script setup>` top-level code, - `onBeforeMount` and `onMounted`, - `onUnmounted` of the old page. What **does** update: - the reactive `route` from `useRoute()`, so templates reading `route.params.id` re-render, - props passed by `props: true` or a props function, - anything computed from them. So a template that shows `route.params.id` is correct, while data fetched once from it is stale. That split is the classic bug. ## The broken version ```ts const route = useRoute() const product = ref<Product | null>(null) onMounted(async () => { product.value = await fetchProduct(String(route.params.id)) }) ``` The heading might show the new id while the body still shows product 1's name and price. ## Fixes, from most to least common 1. **Watch the param.** `watch(() => route.params.id, (id) => load(id), { immediate: true })` loads on first render and on every change. The getter form matters: watching `route.params.id` directly would read the value once. 2. **Watch a prop.** With `props: true` on the record, the component declares `id` as a prop and watches `() => props.id`. The component no longer knows about the router, which makes it easier to test and reuse. 3. **Use the in-component update guard.** `onBeforeRouteUpdate` runs only when the component is reused for a new location and can cancel the navigation or load before it is confirmed. How it fits among the guards is its own subject. 4. **Force a remount.** Keying the view component on `route.fullPath` inside `RouterView`'s slot destroys and recreates it on every change. It is simple but throws away local state and costs a full mount, and it belongs to the view-rendering topic. | Fix | Runs before the view changes | Keeps local state | Router-free component | |---|---|---|---| | watch `route.params.id` | no | yes | no | | watch `props.id` | no | yes | yes | | `onBeforeRouteUpdate` | yes | yes | no | | key on the view | no | no | yes | ## Details that bite - **Race conditions.** Quick clicks from 1 to 2 to 3 start three loads; if the load for 2 finishes last, the page shows 2 while the URL says 3. Ignore stale responses, for example by comparing the id when the response arrives, or abort the earlier request. - **Leaving the route.** A watcher on `route.params.id` can run once with `undefined` while the view is on its way out to a route without that param. Guard the handler with a check. - **Local state survives.** A half-filled review form or an open tab on product 1 is still there on product 2 unless you reset it in the same watcher. - **Query-only changes.** Going from `?tab=specs` to `?tab=reviews` also reuses the instance; watch `route.query.tab` if it drives data. - **Different records, same component.** The reuse depends on the rendered component, not the record, so two records rendering the same component at the same depth also reuse it. ## Proving the fix in a unit test The bug only shows on the **second** navigation, so a test has to make two: 1. Build a router with memory history and the real `/products/:id` record, and mock the product API. 2. Push `/products/1`, await it, mount the page and assert product 1 renders. 3. Push `/products/2` and await it, then flush pending promises so the watcher's load settles. 4. Assert the API mock was called with `'2'` and product 2's name is on screen. A test that mounts once per id never catches the stale-page bug, because each mount runs `onMounted` fresh. ## Why the router works this way Remounting on every param change would redo DOM creation, reset child components and replay enter transitions for what is often a small data swap. Reuse makes the common case cheap and puts the decision about what to refresh in the component, which is where the knowledge of its data lives.

  • When would you choose the key-on-the-view remount over a watcher?
    When the component holds a lot of local state that must reset for each product and resetting it by hand is error-prone, or when third-party child components only initialise on mount. The cost is a full unmount and mount on every change, including child components and enter transitions.
  • Does the reuse also happen when going from /products/1 to /products/1?tab=reviews?
    Yes. The same record matches, so the same instance stays; only `route.query` changes. Any data derived from the query needs its own watcher, and a watcher on `route.params.id` alone will not fire for that navigation.

A stage set that stays up between two scenes: the crew swaps the name plate on the door but does not rebuild the room. If the script only hung the family photo when the set was first built, the old photo stays until someone is told to change it on each scene.

saying these in an interview costs you the question

  • Vue Router destroys and recreates the component on every navigation.
  • onMounted runs again when only the route params change.
  • watch(route.params.id, ...) tracks later param changes.
  • The stale page is a caching bug in the router.
  • A key on the view is the only way to refresh data.