Skip to content

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:

  1. Every thing has a page under /items/ and its number, such as /items/6.
  2. Search text, category and sort order are part of the address, and /?q=saw&category=Tools shows the saws among the tools.
  3. 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 useRoute and pass them on as props,
  • read and write the query of an address,
  • navigate in code with router.push and router.replace,
  • react when the router reuses a component for a new param.

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

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

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

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 numbers
const 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

main.js
import { createApp } from 'vue'
import App from './App.vue'
import router from './router.js'
createApp(App).use(router).mount('#app')

router.js

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

things.js
// A few things of the catalog
export 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 } }.

Parts of an address Two addresses taken apart. At the top /items/6, where /items/ is the fixed part of the route /items/:id and 6 is the param that route.params.id returns as a string. At the bottom /?q=saw&category=Tools&sort=deposit, where / is the path, and the query consists of q, category and sort, returned by route.query.q, route.query.category and route.query.sort. /items/ fixed part of the route /items/:id 6 param route.params.id = '6' / path ?q=saw query route.query.q &category=Tools query route.query.category &sort=deposit query route.query.sort

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

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

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

ItemView.vue
<script setup>
import { computed } from 'vue'
import { things } from './things.js'
// The component knows nothing of the router, it only gets a prop
const 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

main.js
import { createApp } from 'vue'
import App from './App.vue'
import router from './router.js'
createApp(App).use(router).mount('#app')

router.js

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

things.js
// A few things of the catalog
export 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.

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

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

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

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 changes
watch(
() => 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

main.js
import { createApp } from 'vue'
import App from './App.vue'
import router from './router.js'
createApp(App).use(router).mount('#app')

router.js

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

things.js
// A few things of the catalog
export 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 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

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

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 back
const 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

main.js
import { createApp } from 'vue'
import App from './App.vue'
import router from './router.js'
createApp(App).use(router).mount('#app')

router.js

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

things.js
// A few things of the catalog
export 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.

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

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

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 again
function 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

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

main.js
import { createApp } from 'vue'
import App from './App.vue'
import router from './router.js'
createApp(App).use(router).mount('#app')

router.js

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

things.js
// A few things of the catalog
export 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.

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.

Work with the examples of this chapter. Read the code and write down what you expect before you change anything.

  1. In the example with useRoute, remove the call to Number(). What does the page show for the drill, and why?
  2. In the example with the props, replace the function with props: true. What does Vue report in the console?
  3. In the example with the reused component, click "Next thing" several times. How often does "ItemView is created" appear?
  4. In the example with the query, replace router.replace with router.push and add a button that calls router.back(). Type a word and click the button several times. What happens to the search field?

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.

Terminal window
npm install
npm test

Exercise 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
index.js
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) }),
},
],
})
CatalogView.vue
<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
CatalogView.vue
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
CatalogView.vue
<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.

MistakeWhat 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

  1. How does the value of :id get into a component?
  2. Why does the route convert the param into a number?
  3. When does router.replace fit, and when router.push?
  4. What happens to the code in <script setup> when only the param changes?
Answers
  1. Through route.params.id from useRoute, or the route passes it on as a prop with props.
  2. Params arrive as strings, but the numbers of the things are numbers. Without the conversion, the comparison would find no thing.
  3. replace for changes that don't deserve a step back of their own, such as each letter of the search. push for a change of page that the back button should undo.
  4. 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.