Vue Router and the Composition API
Learn how to use Vue Router with Vue 3's Composition API, including new composables and patterns for modern Vue applications.
Overview
Vue 3's Composition API opened up new possibilities for organizing and reusing logic. Vue Router provides several composables that work seamlessly with the Composition API, allowing you to access routing functionality in a more flexible and composable way.
Key Composables
useRouter() and useRoute()
Since we don't have access to this inside setup(), we use dedicated composables to access the router and current route:
<script setup>
import { useRouter, useRoute } from 'vue-router'
const router = useRouter()
const route = useRoute()
function navigateToUser(userId) {
router.push(`/user/${userId}`)
}
function addQueryParam(key, value) {
router.push({
query: {
...route.query,
[key]: value
}
})
}
</script>
<template>
<div>
<p>Current route: {{ route.path }}</p>
<p>User ID: {{ route.params.id }}</p>
<button @click="navigateToUser(123)">Go to User 123</button>
</div>
</template>Reactive Route Object
The route object returned by useRoute() is reactive. You can watch specific properties instead of the entire route object for better performance:
<script setup>
import { useRoute } from 'vue-router'
import { ref, watch, computed } from 'vue'
const route = useRoute()
const userData = ref(null)
const loading = ref(false)
// Watch specific route parameters
watch(
() => route.params.id,
async (newId, oldId) => {
if (newId !== oldId) {
loading.value = true
try {
userData.value = await fetchUser(newId)
} catch (error) {
console.error('Failed to fetch user:', error)
} finally {
loading.value = false
}
}
},
{ immediate: true }
)
// Watch multiple route properties
watch(
() => [route.params.id, route.query.tab],
async ([newId, newTab], [oldId, oldTab]) => {
if (newId !== oldId || newTab !== oldTab) {
await loadUserData(newId, newTab)
}
}
)
// Computed properties based on route
const isProfileTab = computed(() => route.query.tab === 'profile')
const isSettingsTab = computed(() => route.query.tab === 'settings')
</script>Template Access
Note that $router and $route are still available in templates, so you don't need to use composables if you only need them in the template:
<script setup>
// No need to import useRoute/useRouter for template-only usage
</script>
<template>
<div>
<p>Current path: {{ $route.path }}</p>
<button @click="$router.back()">Go Back</button>
<router-link :to="{ name: 'home' }">Home</router-link>
</div>
</template>Navigation Guards in Composition API
Vue Router provides composable versions of in-component navigation guards:
onBeforeRouteUpdate
<script setup>
import { onBeforeRouteUpdate } from 'vue-router'
import { ref } from 'vue'
const userData = ref(null)
const loading = ref(false)
// Equivalent to beforeRouteUpdate option
onBeforeRouteUpdate(async (to, from) => {
// Only fetch if the user ID changed
if (to.params.id !== from.params.id) {
loading.value = true
try {
userData.value = await fetchUser(to.params.id)
} catch (error) {
console.error('Failed to fetch user:', error)
// Optionally prevent navigation on error
return false
} finally {
loading.value = false
}
}
})
</script>onBeforeRouteLeave
<script setup>
import { onBeforeRouteLeave } from 'vue-router'
import { ref, computed } from 'vue'
const formData = ref({
name: '',
email: ''
})
const hasUnsavedChanges = computed(() => {
// Check if form has been modified
return formData.value.name !== '' || formData.value.email !== ''
})
// Prevent leaving with unsaved changes
onBeforeRouteLeave((to, from) => {
if (hasUnsavedChanges.value) {
const answer = window.confirm(
'Do you really want to leave? You have unsaved changes!'
)
// Cancel navigation if user chooses to stay
if (!answer) return false
}
})
// Alternative: Show custom modal
onBeforeRouteLeave((to, from) => {
if (hasUnsavedChanges.value) {
// Show custom confirmation modal
return showConfirmationModal(
'Unsaved Changes',
'You have unsaved changes. Are you sure you want to leave?'
)
}
})
</script>Guard Flexibility
Composition API guards can be used in any component rendered by <router-view>, not just the route component:
<!-- UserProfile.vue (route component) -->
<template>
<div>
<UserHeader :user="user" />
<UserDetails :user="user" />
</div>
</template>
<!-- UserDetails.vue (child component) -->
<script setup>
import { onBeforeRouteLeave } from 'vue-router'
const props = defineProps(['user'])
// This guard works even though this isn't the route component
onBeforeRouteLeave((to, from) => {
// Save user preferences before leaving
saveUserPreferences(props.user.id)
})
</script>Advanced Patterns
Custom Router Composable
Create reusable router logic:
// composables/useNavigation.js
import { useRouter, useRoute } from 'vue-router'
import { computed } from 'vue'
export function useNavigation() {
const router = useRouter()
const route = useRoute()
const currentPath = computed(() => route.path)
const currentParams = computed(() => route.params)
const currentQuery = computed(() => route.query)
function goBack() {
router.back()
}
function goForward() {
router.forward()
}
function navigateWithQuery(path, query = {}) {
router.push({
path,
query: {
...route.query,
...query
}
})
}
function replaceQuery(query) {
router.replace({
query: {
...route.query,
...query
}
})
}
function clearQuery() {
router.replace({ query: {} })
}
return {
router,
route,
currentPath,
currentParams,
currentQuery,
goBack,
goForward,
navigateWithQuery,
replaceQuery,
clearQuery
}
}
// Usage in component
<script setup>
import { useNavigation } from '@/composables/useNavigation'
const { navigateWithQuery, replaceQuery, currentQuery } = useNavigation()
function filterByCategory(category) {
replaceQuery({ category })
}
</script>Route-based Data Fetching
// composables/useRouteData.js
import { ref, watch } from 'vue'
import { useRoute } from 'vue-router'
export function useRouteData(fetchFunction, dependencies = []) {
const route = useRoute()
const data = ref(null)
const loading = ref(false)
const error = ref(null)
const fetchData = async () => {
loading.value = true
error.value = null
try {
data.value = await fetchFunction(route.params, route.query)
} catch (err) {
error.value = err
} finally {
loading.value = false
}
}
// Watch route changes
watch(
() => dependencies.map(dep =>
typeof dep === 'function' ? dep() : route[dep]
),
fetchData,
{ immediate: true }
)
return {
data,
loading,
error,
refetch: fetchData
}
}
// Usage
<script setup>
import { useRouteData } from '@/composables/useRouteData'
const { data: user, loading, error } = useRouteData(
(params, query) => fetchUser(params.id, query.include),
['params.id', 'query.include']
)
</script>Breadcrumb Navigation
// composables/useBreadcrumbs.js
import { computed } from 'vue'
import { useRoute } from 'vue-router'
export function useBreadcrumbs() {
const route = useRoute()
const breadcrumbs = computed(() => {
const matched = route.matched.filter(record => record.meta?.breadcrumb)
return matched.map(record => ({
text: typeof record.meta.breadcrumb === 'function'
? record.meta.breadcrumb(route)
: record.meta.breadcrumb,
to: record.path,
active: record === route.matched[route.matched.length - 1]
}))
})
return { breadcrumbs }
}
// Route configuration
const routes = [
{
path: '/users',
component: UserList,
meta: { breadcrumb: 'Users' }
},
{
path: '/users/:id',
component: UserProfile,
meta: {
breadcrumb: (route) => `User ${route.params.id}`
}
}
]
// Usage in component
<script setup>
import { useBreadcrumbs } from '@/composables/useBreadcrumbs'
const { breadcrumbs } = useBreadcrumbs()
</script>
<template>
<nav class="breadcrumbs">
<router-link
v-for="(crumb, index) in breadcrumbs"
:key="index"
:to="crumb.to"
:class="{ active: crumb.active }"
>
{{ crumb.text }}
</router-link>
</nav>
</template>useLink Composable
Vue Router exposes the internal behavior of RouterLink as a composable for building custom link components:
Basic Usage
<script setup>
import { useLink } from 'vue-router'
import { computed } from 'vue'
const props = defineProps({
to: {
type: [String, Object],
required: true
},
replace: Boolean,
activeClass: String,
exactActiveClass: String
})
const {
route,
href,
isActive,
isExactActive,
navigate
} = useLink(props)
const classes = computed(() => ({
'router-link': true,
'router-link-active': isActive.value,
'router-link-exact-active': isExactActive.value,
[props.activeClass]: isActive.value && props.activeClass,
[props.exactActiveClass]: isExactActive.value && props.exactActiveClass
}))
function handleClick(event) {
if (event.metaKey || event.ctrlKey) {
// Let browser handle Cmd/Ctrl+click for new tab
return
}
event.preventDefault()
navigate(event)
}
</script>
<template>
<a
:href="href"
:class="classes"
@click="handleClick"
>
<slot />
</a>
</template>Custom Link Component
<!-- CustomButton.vue -->
<script setup>
import { useLink } from 'vue-router'
import { computed } from 'vue'
const props = defineProps({
to: [String, Object],
variant: {
type: String,
default: 'primary'
},
size: {
type: String,
default: 'medium'
},
disabled: Boolean
})
const { navigate, isActive } = useLink(props)
const classes = computed(() => [
'custom-button',
`custom-button--${props.variant}`,
`custom-button--${props.size}`,
{
'custom-button--active': isActive.value,
'custom-button--disabled': props.disabled
}
])
function handleClick(event) {
if (props.disabled) {
event.preventDefault()
return
}
navigate(event)
}
</script>
<template>
<button
:class="classes"
:disabled="disabled"
@click="handleClick"
>
<slot />
</button>
</template>
<style scoped>
.custom-button {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
}
.custom-button--primary {
background: #007bff;
color: white;
}
.custom-button--active {
background: #0056b3;
}
.custom-button--disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>External Link Detection
<script setup>
import { useLink } from 'vue-router'
import { computed } from 'vue'
const props = defineProps({
to: [String, Object],
external: Boolean
})
const isExternalLink = computed(() => {
if (props.external) return true
return typeof props.to === 'string' &&
(props.to.startsWith('http') || props.to.startsWith('mailto:'))
})
// Only use useLink for internal links
const linkData = isExternalLink.value ? null : useLink(props)
function handleClick(event) {
if (isExternalLink.value) {
// Handle external links normally
return
}
// Handle internal navigation
event.preventDefault()
linkData.navigate(event)
}
</script>
<template>
<a
:href="isExternalLink ? to : linkData?.href"
:target="isExternalLink ? '_blank' : undefined"
:rel="isExternalLink ? 'noopener noreferrer' : undefined"
@click="handleClick"
>
<slot />
<span v-if="isExternalLink" class="external-icon">↗</span>
</a>
</template>TypeScript Support
Typed Route Parameters
// types/router.d.ts
import 'vue-router'
declare module 'vue-router' {
interface RouteMeta {
requiresAuth?: boolean
title?: string
breadcrumb?: string | ((route: RouteLocationNormalized) => string)
}
}
// Typed route parameters
interface UserRouteParams {
id: string
}
interface PostRouteParams {
userId: string
postId: string
}Typed Composables
// composables/useTypedRoute.ts
import { useRoute } from 'vue-router'
import { computed, ComputedRef } from 'vue'
export function useTypedRoute<T = Record<string, string>>() {
const route = useRoute()
const params = computed(() => route.params as T)
const query = computed(() => route.query)
return {
route,
params,
query
}
}
// Usage
<script setup lang="ts">
import { useTypedRoute } from '@/composables/useTypedRoute'
interface UserParams {
id: string
}
const { params } = useTypedRoute<UserParams>()
// params.value.id is now typed as string
watchEffect(() => {
console.log('User ID:', params.value.id)
})
</script>Typed Navigation
// composables/useTypedRouter.ts
import { useRouter } from 'vue-router'
export function useTypedRouter() {
const router = useRouter()
function navigateToUser(userId: string) {
return router.push({
name: 'user',
params: { id: userId }
})
}
function navigateToPost(userId: string, postId: string) {
return router.push({
name: 'post',
params: { userId, postId }
})
}
return {
router,
navigateToUser,
navigateToPost
}
}Testing with Composition API
Unit Testing
import { mount } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import UserProfile from '@/components/UserProfile.vue'
// Mock the composables
jest.mock('vue-router', () => ({
...jest.requireActual('vue-router'),
useRoute: jest.fn(),
useRouter: jest.fn()
}))
describe('UserProfile with Composition API', () => {
let mockRoute
let mockRouter
beforeEach(() => {
mockRoute = {
params: { id: '123' },
query: {},
path: '/user/123'
}
mockRouter = {
push: jest.fn(),
replace: jest.fn(),
back: jest.fn()
}
useRoute.mockReturnValue(mockRoute)
useRouter.mockReturnValue(mockRouter)
})
it('navigates correctly', async () => {
const wrapper = mount(UserProfile)
await wrapper.find('[data-testid="edit-button"]').trigger('click')
expect(mockRouter.push).toHaveBeenCalledWith({
name: 'user-edit',
params: { id: '123' }
})
})
it('reacts to route changes', async () => {
const wrapper = mount(UserProfile)
// Simulate route change
mockRoute.params.id = '456'
await wrapper.vm.$nextTick()
// Assert component updated
expect(wrapper.vm.userId).toBe('456')
})
})Integration Testing
import { mount } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router'
import App from '@/App.vue'
import UserProfile from '@/components/UserProfile.vue'
describe('Router Integration', () => {
let router
beforeEach(async () => {
router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/user/:id', component: UserProfile, name: 'user' }
]
})
await router.push('/user/123')
})
it('renders correct component for route', async () => {
const wrapper = mount(App, {
global: {
plugins: [router]
}
})
expect(wrapper.findComponent(UserProfile).exists()).toBe(true)
expect(wrapper.text()).toContain('User 123')
})
})Best Practices
1. Use Specific Watchers
// ✅ Good - Watch specific properties
watch(() => route.params.id, fetchUser)
// ❌ Avoid - Watching entire route object
watch(route, () => {
// This triggers on every route change
})2. Cleanup in Guards
// ✅ Good - Cleanup resources
onBeforeRouteLeave(() => {
// Cancel ongoing requests
cancelPendingRequests()
// Clear timers
clearInterval(pollTimer)
// Save state
saveFormData()
})3. Error Handling
// ✅ Good - Handle navigation errors
async function navigateToUser(id) {
try {
await router.push(`/user/${id}`)
} catch (error) {
if (error.name === 'NavigationDuplicated') {
// Already on the same route
return
}
console.error('Navigation failed:', error)
showErrorMessage('Failed to navigate')
}
}4. Composable Organization
// ✅ Good - Focused composables
export function useUserData() {
// Only user-related logic
}
export function useNavigation() {
// Only navigation logic
}
// ❌ Avoid - Monolithic composables
export function useEverything() {
// Too many responsibilities
}Common Pitfalls
1. Reactive Route Watching
// ❌ Wrong - This won't be reactive
const userId = route.params.id
// ✅ Correct - Use computed or watch
const userId = computed(() => route.params.id)2. Guard Timing
// ❌ Wrong - Guard might not be registered in time
setTimeout(() => {
onBeforeRouteLeave(() => {
// This might not work
})
}, 1000)
// ✅ Correct - Register guards immediately
onBeforeRouteLeave(() => {
// This works correctly
})3. Memory Leaks
// ❌ Wrong - Potential memory leak
onBeforeRouteUpdate(() => {
setInterval(() => {
// This interval is never cleared
}, 1000)
})
// ✅ Correct - Cleanup properly
onBeforeRouteUpdate(() => {
const timer = setInterval(() => {
// Do something
}, 1000)
onBeforeRouteLeave(() => {
clearInterval(timer)
})
})Migration from Options API
Before (Options API)
export default {
data() {
return {
user: null
}
},
watch: {
'$route.params.id': {
handler: 'fetchUser',
immediate: true
}
},
beforeRouteUpdate(to, from, next) {
this.fetchUser(to.params.id)
next()
},
methods: {
async fetchUser(id) {
this.user = await api.fetchUser(id)
},
navigateToEdit() {
this.$router.push({
name: 'user-edit',
params: { id: this.user.id }
})
}
}
}After (Composition API)
<script setup>
import { ref, watch } from 'vue'
import { useRoute, useRouter, onBeforeRouteUpdate } from 'vue-router'
const route = useRoute()
const router = useRouter()
const user = ref(null)
async function fetchUser(id) {
user.value = await api.fetchUser(id)
}
function navigateToEdit() {
router.push({
name: 'user-edit',
params: { id: user.value.id }
})
}
// Watch route changes
watch(() => route.params.id, fetchUser, { immediate: true })
// Route guard
onBeforeRouteUpdate(async (to, from) => {
await fetchUser(to.params.id)
})
</script>Next Steps
- Learn about Lazy Loading Routes for code splitting
- Explore Route Meta Fields for storing route-specific data
- Understand Typed Routes for better TypeScript support