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 - 让前端路由变得简单而强大 | 构建现代化单页应用的最佳选择