skip to content

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?

level: middleimportance: must knowfreq 62%

answer

  1. the query is replaced, not merged
  2. spread route.query first
  3. undefined removes a key, null keeps it
  4. values come back as strings
  5. params are ignored beside path

basics

~20 s

Copy 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 s

In 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 lines
ts
import { 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

for a junior

Recall the location shapes, a string, a path object and a named object, and that a query-only object stays on the current route.

for a middle

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.

for a senior

Show a URL-state composable that spreads the query, resets the page, parses values once and watches the query instead of relying on mount.

for a principal

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