On a Vue Router 5 job-search page with filters in the query, how do you change one filter via `router.push` without dropping the others?
answer
- the query is replaced, not merged
- spread route.query first
- undefined removes a key, null keeps it
- values come back as strings
- params are ignored beside path
basics
~20 sCopy the current query into the new location: router.push({ query: { ...route.query, remote: 'true', page: undefined } }). A location with only query stays on the current route, but its query replaces the old one, so omitted keys vanish.
solid answer
~40 sIn Vue Router 5 a location object with no `path` or `name` resolves against the current route, keeping its record and params, but the `query` you pass becomes the whole new query and a missing `hash` is dropped. So I spread `route.query` from `useRoute()` and override one key: `router.push({ query: { ...route.query, remote: 'true', page: undefined } })`. `undefined` removes a key, `null` writes it without a value, numbers are stringified and arrays repeat the key. Reading back, `route.query` values are strings, `null` or arrays, so `page` is `'4'`, not `4`, and I parse it. Two more traps: `params` are ignored when a `path` is given, and destructuring `useRoute()` freezes the query I read.
code
ts · 26 linesimport { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
export function useJobFilters() {
const route = useRoute()
const router = useRouter()
const page = computed(() => Math.max(1, Number(route.query.page) || 1))
const skills = computed(() => {
const raw = route.query.skills
const list = Array.isArray(raw) ? raw : raw == null ? [] : [raw]
return list.filter((s): s is string => typeof s === 'string')
})
function setFilter(key: string, value: string | string[] | undefined) {
return router.replace({
query: { ...route.query, [key]: value, page: undefined },
})
}
function goToPage(n: number) {
return router.push({ query: { ...route.query, page: n } })
}
return { page, skills, setFilter, goToPage }
}go deeper
Recall the location shapes, a string, a path object and a named object, and that a query-only object stays on the current route.
Explain that the pushed query replaces the old one, how null, undefined, numbers and arrays are written, and why route.query values come back as strings.
Show a URL-state composable that spreads the query, resets the page, parses values once and watches the query instead of relying on mount.
Decide which view state belongs in the URL and how it is parsed and validated at one boundary, so shared links and Back behave the same for every team.
## Location objects in Vue Router 5 `router.push()`, `router.replace()` and RouterLink's `to` prop all take a **route location**: a string, or an object in one of three shapes. | Shape | Example | Resolves against | |---|---|---| | path | `{ path: '/jobs', query: { q: 'vue' } }` | the route table, by URL | | named | `{ name: 'jobs', query: { q: 'vue' } }` | the record with that name | | relative | `{ query: { q: 'vue' } }` | the **current** route: same record, same params | On a job-search results page the relative shape is the natural one: the filters change, the route does not. `router.push({ query })` keeps the current record and params and builds a new URL from the query you pass. ## The query is replaced, not merged The key rule: the router never merges your `query` into the current one. The object you pass **is** the new query, and a `hash` you leave out becomes empty. On `/jobs?q=vue&remote=true&page=3`, `router.push({ query: { page: '4' } })` lands on `/jobs?page=4`; the search term and the remote filter are gone. To change one filter, copy the current query first: ```ts const route = useRoute() const router = useRouter() function setFilter(key: string, value: string | undefined) { return router.push({ query: { ...route.query, [key]: value, page: undefined }, }) } ``` Resetting `page` with `undefined` is deliberate: a new filter should start from the first page, and `undefined` removes the key. ## How query values are written and read back When the router builds the URL: - **strings** are encoded and written as `key=value`; - **numbers** are turned into strings, so `page: 2` writes `page=2`; - **`null`** writes the key without a value, as in `?remote`; - **`undefined`** removes the key; - **arrays** repeat the key: `skills: ['vue', 'ts']` writes `skills=vue&skills=ts`. Reading them back through `useRoute().query` gives a string, `null`, or an array of those, never a number. So `route.query.page` is `'4'`, not `4`, and a single `?skills=vue` comes back as the string `'vue'`, not a one-item array. Normalise at the edge: 1. parse numbers with a fallback, such as `Number(route.query.page) || 1`; 2. wrap single values in an array before treating them as a list; 3. validate every value you forward to an API, because users edit URLs. The types say the same thing. The write side, `LocationQueryRaw`, accepts a string, `null`, a number or `undefined` for each value (or an array of them); the read side, `LocationQuery` on `route.query`, holds only `string | null` or arrays of those. So in TypeScript `route.query.page` is typed as a string, `null` or an array of those, never a number, which is the reminder to parse it before doing arithmetic. ## Path, params and the silent drop The second classic loss is `params` beside `path`. `router.push({ path: '/jobs', params: { id: '42' } })` goes to `/jobs`: when a `path` is given, `params` are ignored, and in development the router warns that they will be. Use a named location, `{ name: 'job', params: { id: '42' } }`, and the router builds and encodes the path. If you build a string path yourself, encode each dynamic segment with `encodeURIComponent`. ## Reading the query reactively `useRoute()` returns a **reactive** object whose properties always reflect the current route. Two rules follow: - do not destructure it: `const { query } = useRoute()` copies the query once and never updates; - watch the part you need, such as `() => route.query` or one key of it, and refetch there. The watcher matters on this page because a query-only navigation keeps the same component instance mounted, so code in `onMounted` does not run again when a filter changes. ## Push or replace for filters For a keyword box that navigates on every keystroke, `router.replace()` (or `replace: true` in the location) overwrites the current history entry instead of stacking one entry per character. Which filter changes deserve their own Back step is a product decision; the location object is the same either way. ## Common mistakes - Passing only the changed key and wiping every other filter. - Treating `route.query.page` as a number in arithmetic or comparisons. - Combining `path` and `params` and wondering why the id disappeared. - Destructuring `useRoute()` and rendering stale filters after the next navigation. - Pushing an unchanged query and expecting a refetch: the router treats it as a duplicate and does not navigate.
- Why does `router.push({ query: { ...route.query } })` with nothing changed not refetch the results?The target has the same route record, params, query and hash as the current location, so the router does not navigate: the promise resolves with a `duplicated` navigation failure, no `beforeEach` guard runs and no history entry is added. Pass `force: true` to run the navigation anyway; it then adds an entry unless `replace` is also set.
- What happens to a route param when you push a relative `{ query }` location from `/companies/:companyId/jobs`?It is kept. A location without `path` or `name` resolves against the current record and merges the current params with any you pass, so `companyId` survives and only the query changes. That is why the relative shape suits filter changes on a parameterised results page.
saying these in an interview costs you the question
- Passing only the changed key merges it into the current query
- route.query.page is a number because the code pushed a number
- params work alongside path in a router.push location object
- Setting a query key to null removes it from the URL
- Destructuring useRoute() keeps the query reactive