Fix: Vue Router Params Not Updating — Component Not Re-rendering or beforeRouteUpdate Not Firing
Part of: React & Frontend Errors
Quick Answer
How to fix Vue Router params not updating when navigating between same-route paths — watch $route, beforeRouteUpdate, onBeforeRouteUpdate, and component reuse behavior explained.
The Problem
Navigating from /users/1 to /users/2 doesn’t update the component:
<script setup>
import { useRoute } from 'vue-router'
const route = useRoute()
const userId = route.params.id // Still shows '1' after navigating to /users/2
</script>Or beforeRouteUpdate never fires:
export default {
beforeRouteUpdate(to, from, next) {
// Never called when navigating from /posts/1 to /posts/2
this.fetchPost(to.params.id)
next()
}
}Or a lifecycle hook like onMounted doesn’t re-run when the route changes:
<script setup>
import { onMounted } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
onMounted(async () => {
await fetchData(route.params.id) // Only called once — doesn't refetch on navigation
})
</script>Why This Happens
Vue Router reuses the same component instance when navigating between routes that match the same component. This is an optimization — creating and destroying components on every navigation is expensive — but it means:
- Lifecycle hooks don’t re-fire —
onMounted,created,beforeMountonly run when the component is first created, not when route params change. route.paramsis reactive, but you may not be watching it — readingroute.params.idonce outside a reactive context gives a snapshot, not a live value.beforeRouteUpdateis the intended solution — but it’s easy to forget to callnext(), which stalls navigation.
A second source of confusion is that the Vue Router API has changed substantially across versions. Code written for Vue Router 3 (the Vue 2 era) does not run on Vue Router 4. Code written before typed routes existed needs adjustment to take advantage of them. The fix that solves your problem depends on which Vue Router major you’re on.
Vue Router Version History — What Changed When
- Vue Router 3.x (paired with Vue 2, ~2017–2020) is the version most legacy tutorials reference. It uses
new VueRouter(), theVue.use(VueRouter)plugin install, andthis.$routein the Options API. Composition API support was external (@vue/composition-api). - Vue Router 4.0 (August 2020) shipped with Vue 3 and is a breaking rewrite.
createRouter()replacesnew VueRouter().historybecomes a constructor option (createWebHistory(),createWebHashHistory()). The*wildcard route is replaced by/:pathMatch(.*)*.mode: 'history'no longer works — you’ll get “router.mode is undefined” or silent failure. Most “vue-router not working” Stack Overflow threads conflate Router 3 and Router 4 syntax — confirm yourpackage.jsonfirst. - Vue Router 4.1 (June 2022) introduced typed routes (experimental). You could declare a
RouteNamedMapinterface to get autocomplete onrouter.push({ name: ... }). Still required manual interface declarations. - Vue Router 4.2 (April 2023) stabilized typed routes as a stable feature with the
RouteRecordInfoAPI. This is also whenunplugin-vue-router(third-party) became the recommended path for file-based routing with full TypeScript inference — no manual type declarations needed. - Vue Router 4.3 (March 2024) added improved hot-module-replacement for route updates, smarter
scrollBehaviordefaults, and stricter checks on guard return values (returningundefinedfrom a guard now logs a warning). - Vue Router 4.4 (June 2024) improved typed-routes integration and added
props: truesupport with full inference when used withunplugin-vue-router. Passing params as props (Fix 4 below) gets full type-checking on the destination component. - Nuxt 3 (Nov 2022) ships with file-based routing built on
unplugin-vue-routersemantics.definePageMeta,useRoute, and thepages/directory replace manual route config entirely. Nuxt 3.10+ exposes typed routes by default; older Nuxt 3 minor releases require enablingexperimental.typedPages.
If you’re on Vue 2 + Vue Router 3, the onBeforeRouteUpdate composition function below does not exist — use the Options API beforeRouteUpdate instead. If you’re on Vue Router 4.2+, prefer typed routes (useRoute<'/users/[id]'>()) for better inference.
Fix 1: Watch route.params for Reactive Updates
The most straightforward fix: watch route.params and refetch data when it changes.
<!-- Composition API (Vue 3) -->
<script setup>
import { watch } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
async function fetchUser(id) {
// fetch logic
}
// Watch a specific param
watch(
() => route.params.id,
async (newId, oldId) => {
if (newId !== oldId) {
await fetchUser(newId)
}
},
{ immediate: true } // Also runs on first mount
)
</script>Watch the full route object when multiple params can change:
<script setup>
import { watch } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
watch(
() => route.params,
async (params) => {
await fetchData(params)
},
{ immediate: true, deep: true }
)
// Watch both params and query strings
watch(
() => ({ params: route.params, query: route.query }),
async ({ params, query }) => {
await fetchData(params.id, query.tab)
},
{ immediate: true, deep: true }
)
</script>Options API equivalent:
export default {
watch: {
'$route.params.id': {
immediate: true,
handler(newId) {
this.fetchUser(newId)
}
}
}
}Fix 2: Use onBeforeRouteUpdate (Composition API)
onBeforeRouteUpdate is the Composition API equivalent of the beforeRouteUpdate navigation guard. It only exists in Vue Router 4 — on Router 3 you must use the Options API guard:
<script setup>
import { ref } from 'vue'
import { onBeforeRouteUpdate } from 'vue-router'
const post = ref(null)
async function fetchPost(id) {
const response = await fetch(`/api/posts/${id}`)
post.value = await response.json()
}
// Called when the route changes but the component is reused
onBeforeRouteUpdate(async (to, from) => {
if (to.params.id !== from.params.id) {
await fetchPost(to.params.id)
}
})
// Still need to fetch on initial mount
onMounted(() => fetchPost(route.params.id))
</script>Options API with beforeRouteUpdate (Router 3 and Router 4):
export default {
data() {
return { post: null }
},
async mounted() {
await this.fetchPost(this.$route.params.id)
},
// Called when navigating from /posts/1 to /posts/2 (same component, new params)
async beforeRouteUpdate(to, from, next) {
await this.fetchPost(to.params.id)
next() // MUST call next() or navigation stalls (Router 3 / Router 4 legacy form)
},
methods: {
async fetchPost(id) {
const response = await fetch(`/api/posts/${id}`)
this.post = await response.json()
}
}
}Warning: On Vue Router 4 you may also
return false/return new pathinstead of callingnext(). Returningundefined(implicit) is treated as “continue” but logs a deprecation warning since Router 4.3.
Fix 3: Force Component Re-creation with :key
If re-using the component is causing more problems than it solves, force Vue to destroy and recreate it by binding :key to the route param:
<!-- In the parent or App.vue — bind key to the route -->
<template>
<router-view :key="$route.fullPath" />
</template>More targeted — only re-create for specific routes:
<template>
<router-view :key="routeKey" />
</template>
<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const routeKey = computed(() => {
// Only force re-creation for user profile routes
if (route.name === 'UserProfile') {
return route.params.id
}
// Reuse component for other routes
return route.name
})
</script>Note: Using
:key="$route.fullPath"recreates every component on every navigation, including parent layouts. This can cause unwanted layout re-mounts. PreferwatchoronBeforeRouteUpdatefor most cases.
Fix 4: Use Computed Properties for Reactive Param Access
Instead of reading params once, use computed to stay reactive:
<!-- WRONG — snapshot, not reactive -->
<script setup>
import { useRoute } from 'vue-router'
const route = useRoute()
const userId = route.params.id // Plain string — won't update
// template: {{ userId }} — always shows initial value
</script>
<!-- CORRECT — computed stays reactive -->
<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const userId = computed(() => route.params.id) // Ref — updates automatically
// template: {{ userId }} — updates when route changes
</script>Typed route params with Vue Router 4.4+:
// router/index.ts — typed routes
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/users/:id',
name: 'UserProfile',
component: () => import('@/views/UserProfile.vue'),
props: true // Pass params as component props
}
]
})
// UserProfile.vue — receive params as props (always up-to-date)
<script setup>
const props = defineProps<{
id: string // Received from route params via props: true
}>()
// Watch props instead of route.params
watch(() => props.id, async (newId) => {
await fetchUser(newId)
}, { immediate: true })
</script>File-based routing with unplugin-vue-router (Vue Router 4.2+ recommended path):
// pages/users/[id].vue — file name encodes the route
<script setup lang="ts">
import { useRoute } from 'vue-router/auto'
// Fully typed: route.params has { id: string } inferred from the filename
const route = useRoute('/users/[id]')
watch(() => route.params.id, fetchUser, { immediate: true })
</script>This is the same approach Nuxt 3 uses internally. The route, params, and named-route helpers are all type-checked against the actual file structure — no manual route config, no as casts.
Fix 5: Pattern for Paginated or Filtered Routes
A common scenario: a list page with query params for page/filter:
<!-- /products?page=2&category=electronics -->
<script setup>
import { ref, watch, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
const route = useRoute()
const router = useRouter()
const products = ref([])
const loading = ref(false)
// Reactive query params
const currentPage = computed(() => Number(route.query.page) || 1)
const category = computed(() => route.query.category || '')
// Fetch when query changes
watch(
[currentPage, category],
async ([page, cat]) => {
loading.value = true
try {
products.value = await fetchProducts({ page, category: cat })
} finally {
loading.value = false
}
},
{ immediate: true }
)
// Update URL when user changes filter
function setPage(page) {
router.push({ query: { ...route.query, page } })
}
function setCategory(cat) {
router.push({ query: { ...route.query, category: cat, page: 1 } })
}
</script>Fix 6: Global Navigation Guard for Consistent Data Loading
For data that needs to be loaded before the route renders, use beforeEnter or a global beforeEach:
// router/index.ts
const router = createRouter({ /* ... */ })
// Per-route guard
const routes = [
{
path: '/users/:id',
component: UserProfile,
async beforeEnter(to, from) {
// Called on initial navigation AND when params change
// (but NOT when reusing component — use beforeRouteUpdate for that)
try {
const user = await fetchUser(to.params.id)
to.meta.user = user // Pass data via meta
} catch {
return { name: 'NotFound' } // Redirect if user not found
}
}
}
]
// UserProfile.vue — access data from meta
<script setup>
import { useRoute } from 'vue-router'
const route = useRoute()
const user = route.meta.user // Pre-loaded by beforeEnter
</script>Combine beforeEnter and beforeRouteUpdate for complete coverage:
// routes/user.js
const userRouteConfig = {
path: '/users/:id',
component: UserProfile,
async beforeEnter(to) {
// Runs on first navigation to this route
await loadUser(to.params.id, to)
}
}
// UserProfile.vue
onBeforeRouteUpdate(async (to) => {
// Runs when navigating between /users/1 → /users/2
await loadUser(to.params.id, to)
})Still Not Working?
watch with immediate: true but data still stale on first render — if watch with immediate: true fires asynchronously and your template renders before the data loads, show a loading state:
<template>
<div v-if="loading">Loading...</div>
<div v-else>{{ user?.name }}</div>
</template>
<script setup>
const user = ref(null)
const loading = ref(true)
watch(
() => route.params.id,
async (id) => {
loading.value = true
user.value = await fetchUser(id)
loading.value = false
},
{ immediate: true }
)
</script>Navigation guard works but component data is from the previous route — beforeRouteUpdate receives to and from as the new and old routes. Make sure you’re using to.params.id, not this.$route.params.id (which may not be updated yet when the guard fires).
router-link with same destination doesn’t navigate — Vue Router ignores navigation to the current route by default. If a user clicks a <router-link> for the current page, nothing happens. To force a reload, either add :key to <router-view> or handle it explicitly with router.push() and catch the NavigationDuplicated error.
Params disappear after programmatic navigation — when using router.push({ name: 'Route' }) without specifying params, all params reset. Always include current params if you want to preserve them: router.push({ name: 'Route', params: { ...route.params, newParam: 'value' } }).
Code from a tutorial uses this.$router.push syntax in <script setup> and fails — <script setup> has no this. You need to import useRouter: const router = useRouter(); router.push(...). This is the most common Vue 2 → Vue 3 migration trap for routing.
Nuxt 3 page does not re-fetch on param change — Nuxt’s useFetch and useAsyncData re-execute when their reactive dependencies change. Use a computed key: useFetch(() => \/api/users/${route.params.id}`)or passwatch: [() => route.params.id]` to the options. The function form is required — passing a string template URL evaluates once.
unplugin-vue-router types not picked up in IDE — add vue-router/auto-routes and vue-router/auto to your tsconfig.json types array, and make sure your IDE’s Volar/TypeScript service is restarted after installing the plugin.
For related Vue Router issues, see Fix: Vue Router 404 on Refresh, Fix: Vue Router Navigation Guard, Fix: Vue Reactive Data Not Updating, and Fix: Pinia Store Not Working.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Nuxt Not Working — useFetch Returns Undefined, Server Route 404, or Hydration Mismatch
How to fix Nuxt 3 issues — useFetch vs $fetch, server routes in server/api, composable SSR rules, useAsyncData, hydration errors, and Nitro configuration problems.
Fix: Vue Slot Not Working — Named Slots Not Rendering or Scoped Slot Data Not Accessible
How to fix Vue 3 slot issues — v-slot syntax, named slots, scoped slots passing data, default slot content, fallback content, and dynamic slot names.
Fix: Vue Teleport Not Rendering — Content Not Appearing at Target Element
How to fix Vue Teleport not working — target element not found, SSR with Teleport, disabled prop, multiple Teleports to the same target, and timing issues.
Fix: Vue Composable Not Reactive — ref and reactive Losing Reactivity After Destructuring
How to fix Vue composable reactivity loss — toRefs for destructuring, returning refs vs raw values, reactive object pitfalls, stale closures, and composable design patterns.