Params and query
Assignment
Martina Hostettler wants to be able to send links.
As a member, I want to send another member a link to a thing or to a filtered list, so that we both see the same page.
The acceptance criteria are:
- Every thing has a page under
/items/and its number, such as/items/6. - Search text, category and sort order are part of the address, and
/?q=saw&category=Toolsshows the saws among the tools. - The back button of the browser leads from the thing back to the filtered list.
Learning goals
After this chapter you can
- define routes with params,
- read params with
useRouteand pass them on as props, - read and write the query of an address,
- navigate in code with
router.pushandrouter.replace, - react when the router reuses a component for a new param.
Routes with params
Section titled “Routes with params”A colon turns part of the path into a param. The route /items/:id matches /items/1 as well as /items/6, and route.params.id returns the number from the address.
App.vue
<script setup>import { RouterLink, RouterView, useRoute } from 'vue-router'
const route = useRoute()</script>
<template> <nav> <RouterLink :to="{ name: 'catalog' }">Catalog</RouterLink> </nav> <p>Address: {{ route.fullPath }}</p> <RouterView /></template>
<style scoped>nav { display: flex; flex-wrap: wrap; gap: 1rem;}</style>CatalogView.vue
<script setup>import { RouterLink } from 'vue-router'
import { things } from './things.js'</script>
<template> <h1>Catalog</h1> <ul> <li v-for="thing in things" :key="thing.id"> <RouterLink :to="{ name: 'item', params: { id: thing.id } }">{{ thing.name }}</RouterLink> </li> </ul></template>ItemView.vue
<script setup>import { computed } from 'vue'import { useRoute } from 'vue-router'
import { things } from './things.js'
const route = useRoute()
// Params always arrive as text, the ids of the things are numbersconst thing = computed(() => things.find((entry) => entry.id === Number(route.params.id)))</script>
<template> <p>route.params.id: {{ JSON.stringify(route.params.id) }}</p> <h1 v-if="thing">{{ thing.name }}</h1> <p v-else>This thing does not exist.</p></template>main.js
import { createApp } from 'vue'
import App from './App.vue'import router from './router.js'
createApp(App).use(router).mount('#app')router.js
import { createMemoryHistory, createRouter } from 'vue-router'
import CatalogView from './CatalogView.vue'import ItemView from './ItemView.vue'
export default createRouter({ history: createMemoryHistory(), routes: [ { path: '/', name: 'catalog', component: CatalogView }, // :id is a param, /items/6 shows the page with the id 6 { path: '/items/:id', name: 'item', component: ItemView }, ],})things.js
// A few things of the catalogexport const things = [ { id: 1, name: 'Drill', deposit: 20 }, { id: 2, name: 'Tent for four', deposit: 50 }, { id: 5, name: 'Jigsaw', deposit: 20 }, { id: 6, name: 'Projector', deposit: 80 }, { id: 15, name: 'Circular saw', deposit: 40 },]The line above the heading shows the value in quotes, because params always arrive as strings. The numbers of the things are numbers, though, so Number() converts the param before the comparison. A link to the page of a thing names the route and adds the param, { name: 'item', params: { id: 6 } }.
Params as props
Section titled “Params as props”A component that reads useRoute only works with a router. It's simpler when the route hands it the values as props. props: true passes the params as they are, which means as strings. A function gets the route and returns the props, and it can convert the param along the way.
App.vue
<script setup>import { RouterLink, RouterView, useRoute } from 'vue-router'
const route = useRoute()</script>
<template> <nav> <RouterLink :to="{ name: 'catalog' }">Catalog</RouterLink> </nav> <p>Address: {{ route.fullPath }}</p> <RouterView /></template>
<style scoped>nav { display: flex; flex-wrap: wrap; gap: 1rem;}</style>CatalogView.vue
<script setup>import { RouterLink } from 'vue-router'
import { things } from './things.js'</script>
<template> <h1>Catalog</h1> <ul> <li v-for="thing in things" :key="thing.id"> <RouterLink :to="{ name: 'item', params: { id: thing.id } }">{{ thing.name }}</RouterLink> </li> </ul></template>ItemView.vue
<script setup>import { computed } from 'vue'
import { things } from './things.js'
// The component knows nothing of the router, it only gets a propconst props = defineProps({ id: { type: Number, required: true } })
const thing = computed(() => things.find((entry) => entry.id === props.id))</script>
<template> <p>Prop id: {{ JSON.stringify(id) }}</p> <h1 v-if="thing">{{ thing.name }}</h1> <p v-else>This thing does not exist.</p></template>main.js
import { createApp } from 'vue'
import App from './App.vue'import router from './router.js'
createApp(App).use(router).mount('#app')router.js
import { createMemoryHistory, createRouter } from 'vue-router'
import CatalogView from './CatalogView.vue'import ItemView from './ItemView.vue'
export default createRouter({ history: createMemoryHistory(), routes: [ { path: '/', name: 'catalog', component: CatalogView }, { path: '/items/:id', name: 'item', component: ItemView, // The route turns the param into a number and passes it as a prop props: (route) => ({ id: Number(route.params.id) }), }, ],})things.js
// A few things of the catalogexport const things = [ { id: 1, name: 'Drill', deposit: 20 }, { id: 2, name: 'Tent for four', deposit: 50 }, { id: 5, name: 'Jigsaw', deposit: 20 }, { id: 6, name: 'Projector', deposit: 80 }, { id: 15, name: 'Circular saw', deposit: 40 },]ItemView now gets a number and no longer knows anything about the router. A test can check the component with mount(ItemView, { props: { id: 6 } }), without an address and without a router. The documentation advises writing the function without state of its own, because the router only calls it when the route changes.
Same component, new params
Section titled “Same component, new params”When a link leads from /items/1 to /items/2, the router keeps the component and only changes the param. The code in <script setup> then doesn't run a second time.
App.vue
<script setup>import { RouterLink, RouterView, useRoute } from 'vue-router'
const route = useRoute()</script>
<template> <nav> <RouterLink :to="{ name: 'catalog' }">Catalog</RouterLink> </nav> <p>Address: {{ route.fullPath }}</p> <RouterView /></template>
<style scoped>nav { display: flex; flex-wrap: wrap; gap: 1rem;}</style>CatalogView.vue
<script setup>import { RouterLink } from 'vue-router'
import { things } from './things.js'</script>
<template> <h1>Catalog</h1> <ul> <li v-for="thing in things" :key="thing.id"> <RouterLink :to="{ name: 'item', params: { id: thing.id } }">{{ thing.name }}</RouterLink> </li> </ul></template>ItemView.vue
<script setup>import { computed, watch } from 'vue'import { RouterLink, useRoute } from 'vue-router'
import { things } from './things.js'
const route = useRoute()console.log('ItemView is created')
// From /items/1 to /items/2 Vue keeps the component, only the param changeswatch( () => route.params.id, (id) => console.log('new id', id),)
const index = computed(() => things.findIndex((entry) => entry.id === Number(route.params.id)))const next = computed(() => things[(index.value + 1) % things.length])</script>
<template> <h1 v-if="index > -1">{{ things[index].name }}</h1> <p v-else>This thing does not exist.</p> <RouterLink :to="{ name: 'item', params: { id: next.id } }">Next thing</RouterLink></template>main.js
import { createApp } from 'vue'
import App from './App.vue'import router from './router.js'
createApp(App).use(router).mount('#app')router.js
import { createMemoryHistory, createRouter } from 'vue-router'
import CatalogView from './CatalogView.vue'import ItemView from './ItemView.vue'
export default createRouter({ history: createMemoryHistory(), routes: [ { path: '/', name: 'catalog', component: CatalogView }, { path: '/items/:id', name: 'item', component: ItemView }, ],})things.js
// A few things of the catalogexport const things = [ { id: 1, name: 'Drill', deposit: 20 }, { id: 2, name: 'Tent for four', deposit: 50 }, { id: 5, name: 'Jigsaw', deposit: 20 }, { id: 6, name: 'Projector', deposit: 80 }, { id: 15, name: 'Circular saw', deposit: 40 },]The console reports "ItemView is created" only for the first thing. After that, only the watcher reports the new number. Whatever depends on the param therefore goes into computed, and whatever should happen on the change goes into a watcher. That's how the running case app resets the form when another thing appears. The documentation also mentions onBeforeRouteUpdate, which runs before the change and can cancel it as well.
The query
Section titled “The query”The part after the question mark is the query. It suits values that change a page without making it a different page, such as search, filter and sort order. route.query.q reads the search text, and a link with query sets it.
App.vue
<script setup>import { RouterLink, RouterView, useRoute } from 'vue-router'
const route = useRoute()</script>
<template> <nav> <RouterLink :to="{ name: 'catalog' }">Catalog</RouterLink> </nav> <p>Address: {{ route.fullPath }}</p> <RouterView /></template>
<style scoped>nav { display: flex; flex-wrap: wrap; gap: 1rem;}</style>CatalogView.vue
<script setup>import { computed } from 'vue'import { RouterLink, useRoute, useRouter } from 'vue-router'
import { things } from './things.js'
const route = useRoute()const router = useRouter()
// The search text lives in the address, the field reads it from there and writes it backconst query = computed({ get: () => route.query.q ?? '', // replace instead of push, so the back button skips the single keystrokes set: (text) => router.replace({ query: { ...route.query, q: text || undefined } }),})
const visibleThings = computed(() => { const text = query.value.trim().toLowerCase() return things.filter((thing) => thing.name.toLowerCase().includes(text))})</script>
<template> <h1>Catalog</h1> <label for="search">Search</label> <input id="search" v-model="query" type="search"> <ul> <li v-for="thing in visibleThings" :key="thing.id">{{ thing.name }}</li> </ul> <RouterLink :to="{ name: 'catalog', query: { q: 'saw' } }">All saws</RouterLink></template>main.js
import { createApp } from 'vue'
import App from './App.vue'import router from './router.js'
createApp(App).use(router).mount('#app')router.js
import { createMemoryHistory, createRouter } from 'vue-router'
import CatalogView from './CatalogView.vue'
export default createRouter({ history: createMemoryHistory(), routes: [{ path: '/', name: 'catalog', component: CatalogView }],})things.js
// A few things of the catalogexport const things = [ { id: 1, name: 'Drill', deposit: 20 }, { id: 2, name: 'Tent for four', deposit: 50 }, { id: 5, name: 'Jigsaw', deposit: 20 }, { id: 6, name: 'Projector', deposit: 80 }, { id: 15, name: 'Circular saw', deposit: 40 },]The search field binds a computed value with get and set. get reads the text from the address, and set writes every change back with router.replace. replace replaces the current entry of the history. Otherwise every keystroke would add a new entry, and the back button would go back letter by letter. A value of undefined removes the key from the address altogether.
In the running case app, search text, category and sort order move into the query. The search field writes to the address only after the pause of 300 milliseconds from chapter 5.2, while category and sort order go there right away. The three values no longer need the browser storage from part 5. A reload keeps the address, the back button restores it, and a link passes it on.
Navigating in code
Section titled “Navigating in code”Sometimes code leads to the next page instead of a link, for example after a form has been sent. useRouter returns the router, and router.push takes the same targets as RouterLink.
App.vue
<script setup>import { RouterLink, RouterView, useRoute } from 'vue-router'
const route = useRoute()</script>
<template> <nav> <RouterLink :to="{ name: 'catalog' }">Catalog</RouterLink> </nav> <p>Address: {{ route.fullPath }}</p> <RouterView /></template>
<style scoped>nav { display: flex; flex-wrap: wrap; gap: 1rem;}</style>CatalogView.vue
<script setup>import { ref } from 'vue'import { useRouter } from 'vue-router'
const router = useRouter()const number = ref(6)
// push adds an entry to the history, the back button leads here againfunction open() { router.push({ name: 'item', params: { id: number.value } })}</script>
<template> <h1>Catalog</h1> <form @submit.prevent="open"> <label for="number">Number of the thing</label> <input id="number" v-model.number="number" type="number" min="1"> <button type="submit">Open</button> </form></template>ItemView.vue
<script setup>import { computed } from 'vue'import { useRouter } from 'vue-router'
import { things } from './things.js'
const props = defineProps({ id: { type: Number, required: true } })const router = useRouter()
const thing = computed(() => things.find((entry) => entry.id === props.id))</script>
<template> <h1 v-if="thing">{{ thing.name }}</h1> <p v-else>This thing does not exist.</p> <!-- back goes one step back in the history, like the button of the browser --> <button type="button" @click="router.back()">Back</button></template>main.js
import { createApp } from 'vue'
import App from './App.vue'import router from './router.js'
createApp(App).use(router).mount('#app')router.js
import { createMemoryHistory, createRouter } from 'vue-router'
import CatalogView from './CatalogView.vue'import ItemView from './ItemView.vue'
export default createRouter({ history: createMemoryHistory(), routes: [ { path: '/', name: 'catalog', component: CatalogView }, { path: '/items/:id', name: 'item', component: ItemView, props: (route) => ({ id: Number(route.params.id) }) }, ],})things.js
// A few things of the catalogexport const things = [ { id: 1, name: 'Drill', deposit: 20 }, { id: 2, name: 'Tent for four', deposit: 50 }, { id: 5, name: 'Jigsaw', deposit: 20 }, { id: 6, name: 'Projector', deposit: 80 }, { id: 15, name: 'Circular saw', deposit: 40 },]push adds an entry to the history, replace replaces the current one, and back goes back one step like the button of the browser. According to the documentation, the router ignores params when a target has a path, which is why the example uses the name of the route. push and replace return a promise that resolves once the navigation is over.
Addresses for pages and for the server
Section titled “Addresses for pages and for the server”The page of a thing lives at /items/6. Until now, the development server of the running case app forwarded everything below /items to the small server, and this page with it. From this part on, the app fetches the catalog from /api/items. Vite now only forwards addresses below /api and removes the prefix, and all other addresses belong to the app.
In code
Section titled “In code”Work with the examples of this chapter. Read the code and write down what you expect before you change anything.
- In the example with
useRoute, remove the call toNumber(). What does the page show for the drill, and why? - In the example with the props, replace the function with
props: true. What does Vue report in the console? - In the example with the reused component, click "Next thing" several times. How often does "ItemView is created" appear?
- In the example with the query, replace
router.replacewithrouter.pushand add a button that callsrouter.back(). Type a word and click the button several times. What happens to the search field?
Exercises
Section titled “Exercises”The exercises build the links from the assignment. You'll find the project in the course repo in the folder exercises/06-routing/params-query/en. Copy the folder, install the packages and start the tests.
npm installnpm testExercise Getting started
ItemView shows a thing and expects its number as the prop id of type Number. In src/router/index.js, add the route /items/:id with the name item, and give ItemView the number as a number. The name of every thing in the catalog leads to its page with RouterLink.
Solution
routes: [ { path: '/', name: 'catalog', component: CatalogView }, { path: '/items/:id', name: 'item', component: ItemView, // Params arrive as text, the route turns the id into a number for the prop props: (route) => ({ id: Number(route.params.id) }), }, ],}) <ul class="catalog"> <li v-for="item in visibleItems" :key="item.id"> <RouterLink :to="{ name: 'item', params: { id: item.id } }" class="name">{{ item.name }}</RouterLink> <span class="category">{{ item.category }}</span> </li> </ul>With props: true, the number would arrive as a string. ItemView would then find no thing, because '6' isn't equal to 6, and Vue would warn about the wrong type of the prop.
Exercise Going further
The search belongs in the address. The search field reads the text from route.query.q and writes every change into the query as q with router.replace. A link like /?q=tent shows the three tents right away, and an empty field removes q from the address.
Solution
const route = useRoute()const router = useRouter()
// Search text and sort live in the query, so a link shows the same list.// replace instead of push, the back button should not step through every keystroke.const query = computed({ get: () => route.query.q ?? '', set: (text) => router.replace({ query: { ...route.query, q: text || undefined } }),})...route.query keeps the other values of the query, so that a sort order added later survives. The field binds query with v-model like an ordinary ref, and visibleItems reads query.value as before.
Exercise Challenge
Add a <select id="sort"> with "Name" for the value '' and "Deposit" for the value deposit. It writes sort=deposit into the query. Without a choice, the catalog sorts by name. With deposit it sorts by deposit, and by name for equal deposits. The search stays when the sort order changes.
Solution
<script setup>import { computed } from 'vue'import { RouterLink, useRoute, useRouter } from 'vue-router'
import items from '../../data/items.json'
const route = useRoute()const router = useRouter()
// Search text and sort live in the query, so a link shows the same list.// replace instead of push, the back button should not step through every keystroke.const query = computed({ get: () => route.query.q ?? '', set: (text) => router.replace({ query: { ...route.query, q: text || undefined } }),})
const sort = computed({ get: () => route.query.sort ?? '', set: (value) => router.replace({ query: { ...route.query, sort: value || undefined } }),})
const byName = (a, b) => a.name.localeCompare(b.name, 'en')const byDeposit = (a, b) => a.deposit - b.deposit || byName(a, b)
const visibleItems = computed(() => { const text = query.value.trim().toLowerCase() const found = items.filter((item) => item.name.toLowerCase().includes(text)) return found.toSorted(sort.value === 'deposit' ? byDeposit : byName)})</script>
<template> <h1>Catalog</h1> <div class="filters"> <div class="search"> <label for="search">Search</label> <input id="search" v-model="query" type="search"> </div> <div class="filter"> <label for="sort">Sort by</label> <select id="sort" v-model="sort"> <option value="">Name</option> <option value="deposit">Deposit</option> </select> </div> </div> <ul class="catalog"> <li v-for="item in visibleItems" :key="item.id"> <RouterLink :to="{ name: 'item', params: { id: item.id } }" class="name">{{ item.name }}</RouterLink> <span class="category">{{ item.category }}</span> </li> </ul></template>toSorted returns a new sorted array and leaves the data unchanged. a.deposit - b.deposit || byName(a, b) goes on to sort by name when two things have the same deposit, because the difference is 0 then.
Common mistakes
Section titled “Common mistakes”| Mistake | What helps |
|---|---|
route.params.id === 6 is never true. | Params are strings. Convert them with Number(), best in the props function of the route. |
router.push({ path: '/items', params: { id: 6 } }) ends up at /items. | With path, the router ignores params. Use the name of the route. |
After a change from /items/1 to /items/2, the page still shows the old thing. | The component stays, only the param changes. Compute the value from the param with computed instead of copying it once. |
| Every keystroke in the search field adds an entry to the history. | Use router.replace instead of router.push. |
| Of two changes to the query in a row, the first one gets lost. | route only changes after the navigation. Change both values in one call, or wait with await until the first navigation is over. |
An AI assistant reads this.$route.params in <script setup>. | There is no this there. useRoute() returns the current route. |
Self-check
Section titled “Self-check”Self-check
- How does the value of
:idget into a component? - Why does the route convert the param into a number?
- When does
router.replacefit, and whenrouter.push? - What happens to the code in
<script setup>when only the param changes?
Answers
- Through
route.params.idfromuseRoute, or the route passes it on as a prop withprops. - Params arrive as strings, but the numbers of the things are numbers. Without the conversion, the comparison would find no thing.
replacefor changes that don't deserve a step back of their own, such as each letter of the search.pushfor a change of page that the back button should undo.- It doesn't run again, because the router keeps the component. Only computed values and watchers react to the new param.
Assignment
Members can now send each other links. At the counter, the team asks for an area of its own. "We need an overview of what's on loan, and a place where we record returns." Several pages under a shared address are what nested routes are for, and they come in the next chapter.
Text and figures are licensed under CC BY-SA 4.0, the code under the MIT License and the course data under CC0 1.0. The figures use the font Kalam under the SIL Open Font License 1.1. The sources are on GitHub.