Routing and state with the Composition API

Vue Router routes, guards and lazy loading, plus a Pinia store you can read from any component without prop drilling.

Routes and navigation

// router.js
import { createRouter, createWebHistory } from 'vue-router'

const routes = [
  { path: '/', component: HomeView },
  { path: '/guides/:slug', component: () => import('./views/Guide.vue'), props: true },
  {
    path: '/admin',
    component: () => import('./views/Admin.vue'),
    meta: { requiresAuth: true },
    children: [{ path: 'users', component: () => import('./views/Users.vue') }]
  },
  { path: '/:pathMatch(.*)*', component: NotFound }   // catch-all last
]

export const router = createRouter({ history: createWebHistory(), routes })

router.beforeEach((to) => {
  if (to.meta.requiresAuth && !isSignedIn()) return { name: 'login', query: { next: to.fullPath } }
})
  • createWebHistory uses real paths and needs a server rewrite of unknown paths to index.html; createWebHashHistory avoids that but puts a hash in every URL.
  • A dynamic :slug segment arrives through useRoute().params.slug, or as a prop when you set props: true.
  • Return a route location from beforeEach to redirect, or false to cancel navigation — do not call next() and also return a value.
  • Components imported with () => import(...) become separate chunks, so the first visit to a route downloads only that route's code.
<RouterLink :to="{ name: 'guide', params: { slug: 'intro' } }">Intro</RouterLink>
<RouterView />   <!-- the matched component renders here -->

Shared state with Pinia

// stores/cart.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useCart = defineStore('cart', () => {
  const items = ref([])
  const total = computed(() => items.value.reduce((sum, i) => sum + i.price * i.qty, 0))

  function add(item) {
    const existing = items.value.find(i => i.id === item.id)
    existing ? (existing.qty += 1) : items.value.push({ ...item, qty: 1 })
  }

  return { items, total, add }
})
import { storeToRefs } from 'pinia'
import { useCart } from '@/stores/cart'

const cart = useCart()
cart.add(product)                          // actions are just methods

const { items, total } = storeToRefs(cart) // refs that stay connected
// reading cart.total in a template is reactive without storeToRefs
⚠️
Destructuring a store directly (const { total } = cart) copies the value and breaks reactivity for primitives. Use storeToRefs, and keep actions off the destructured object.

Project structure and data loading

  • views/ for route-level components, components/ for reusable pieces, stores/ for Pinia, composables/ for shared logic.
  • Keep server calls in composables or stores, not inside template expressions — a fetch in a template re-runs on every render.
  • Load data in onMounted or in a router guard; if a page needs data before it renders for SEO, you need Nuxt or manual server rendering rather than plain Vue.
  • Handle the three states explicitly: loading, error, empty. Most Vue bug reports are a component that only implements the success path.
// composables/useGuides.js
import { ref } from 'vue'

export function useGuides() {
  const data = ref([])
  const error = ref(null)
  const loading = ref(true)

  async function load() {
    loading.value = true
    try {
      const res = await fetch('/api/guides')
      if (!res.ok) throw new Error('HTTP ' + res.status)
      data.value = await res.json()
    } catch (e) {
      error.value = e.message
    } finally {
      loading.value = false
    }
  }

  return { data, error, loading, load }
}

FAQ

Pinia or a plain reactive module?
A module exporting refs works for tiny apps and has no dependency. Pinia pays off once you need devtools, multiple stores with cross-dependencies, or SSR-safe state per request.
Do I need Vuex?
No. Vuex is the previous generation; Pinia is the current recommendation for Vue 3 and has a smaller API surface.

Components, props and events Async JavaScript and fetch

Last refreshed 2026-09-18.