Skip to content

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:

vue
<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:

vue
<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:

vue
<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>

Vue Router provides composable versions of in-component navigation guards:

onBeforeRouteUpdate

vue
<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

vue
<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:

vue
<!-- 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:

javascript
// 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

javascript
// 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>
javascript
// 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>

Vue Router exposes the internal behavior of RouterLink as a composable for building custom link components:

Basic Usage

vue
<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>
vue
<!-- 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>
vue
<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

typescript
// 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

typescript
// 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

typescript
// 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

javascript
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

javascript
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

javascript
// ✅ 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

javascript
// ✅ Good - Cleanup resources
onBeforeRouteLeave(() => {
  // Cancel ongoing requests
  cancelPendingRequests()
  
  // Clear timers
  clearInterval(pollTimer)
  
  // Save state
  saveFormData()
})

3. Error Handling

javascript
// ✅ 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

javascript
// ✅ 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

javascript
// ❌ 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

javascript
// ❌ 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

javascript
// ❌ 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)

javascript
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)

vue
<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

🚀 Vue Router - 让前端路由变得简单而强大 | 构建现代化单页应用的最佳选择