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 } }
})createWebHistoryuses real paths and needs a server rewrite of unknown paths toindex.html;createWebHashHistoryavoids that but puts a hash in every URL.- A dynamic
:slugsegment arrives throughuseRoute().params.slug, or as a prop when you setprops: true. - Return a route location from
beforeEachto redirect, orfalseto cancel navigation — do not callnext()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
onMountedor 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.
Related
Components, props and events Async JavaScript and fetch
Last refreshed 2026-09-18.