State in the URL

Filters, tabs and page numbers belong in the query string, where they survive a reload and travel inside a link.

Syntaxrouter.replace({ path: route.path, query: { ...route.query, status: 'closed' } })

Some state describes what the page is showing: which tab is open, which filter is applied, which page of results you are on. Hold it in a ref and it disappears on reload and cannot be sent to anyone. Put it in the URL's query string and the address bar becomes the state, so a link reproduces the screen.

Reading it

useRoute() returns the current route location, and it is reactive. Read route.query.status inside a computed and it recomputes on every navigation, including the back button. Query values come from text, so each one is typed string | null, or an array of those when a key appears more than once. Parse and default every read; route.query.page is never a number.

Writing it

useRouter() returns the router. Use router.replace for changing a filter, so the back button does not have to step through every keystroke, and router.push when the change is a step worth undoing, such as opening a record. A writable computed with get and set turns a query key into something v-model can bind to.

Trips people up

Do not copy the query into a local ref and keep both in step. That is two sources of truth, and they drift the first time someone edits the address bar. Derive from the route instead. Pass the current path with the query as well, so the target is explicit rather than inferred.

Example

Example · vue
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter, type LocationQueryValue } from 'vue-router'

const route = useRoute()
const router = useRouter()

function first(value: LocationQueryValue | LocationQueryValue[]): string | null {
  return Array.isArray(value) ? value[0] ?? null : value
}

function setQuery(patch: Record<string, string | null>): void {
  const query = { ...route.query }
  for (const [key, value] of Object.entries(patch)) {
    if (value === null) delete query[key]
    else query[key] = value
  }
  void router.replace({ path: route.path, query })
}

const status = computed<string>({
  get: () => first(route.query.status) ?? 'open',
  set: (value) => setQuery({ status: value === 'open' ? null : value, page: null })
})
const page = computed<number>({
  get: () => Number(first(route.query.page) ?? 1),
  set: (value) => setQuery({ page: value > 1 ? String(value) : null })
})
</script>

<template>
  <div class="filters">
    <select v-model="status">
      <option value="open">Open</option>
      <option value="closed">Closed</option>
    </select>
    <button type="button" :disabled="page <= 1" @click="page -= 1">Previous</button>
    <button type="button" @click="page += 1">Next</button>
  </div>
</template>

<style scoped>
.filters { display: flex; align-items: center; gap: 0.5rem; }
</style>

When to use it

  • A results page whose filters survive a reload and can be pasted into a chat.
  • Tabs that the browser back button steps through one at a time.
  • A paginated table where page three is a real address.
  • A saved search that a support agent sends to a colleague as a link.

More examples

One query key as a reusable composable

A writable computed is a Ref, so a component can write const tab = useQueryParam('tab', 'open') and bind v-model="tab" without knowing the URL is involved. Reach for this once two or three pages each need a filter in the query string. Because it calls useRoute and useRouter, it must be called during setup, like any composable that uses injection.

Example · typescript
// src/composables/useQueryParam.ts
import { computed, type Ref } from 'vue'
import { useRoute, useRouter, type LocationQueryValue } from 'vue-router'

function first(value: LocationQueryValue | LocationQueryValue[]): string | null {
  return Array.isArray(value) ? value[0] ?? null : value
}

export function useQueryParam(key: string, fallback: string): Ref<string> {
  const route = useRoute()
  const router = useRouter()

  return computed<string>({
    get: () => first(route.query[key]) ?? fallback,
    set: (value) => {
      const query = { ...route.query }
      if (value === fallback) delete query[key]
      else query[key] = value
      void router.replace({ path: route.path, query })
    }
  })
}
// Output: on /issues?tab=closed, useQueryParam('tab', 'open').value is 'closed'
// Output: on /issues, the same call gives 'open'

Reacting to the URL instead of to the click

IssueResults.vue fetches whenever ?q changes, and { immediate: true } makes it fetch once on mount too, so the first render and a later back-button navigation take the same path through the code. The watcher's source is a getter, () => route.query.q, because watch needs something to call again; passing route.query.q directly would watch a plain string that never changes. Overlapping responses still need handling, which the race conditions lesson covers.

Example · vue
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useRoute } from 'vue-router'

interface Issue {
  id: string
  title: string
}

const route = useRoute()
const issues = ref<Issue[]>([])
const loading = ref(false)

async function search(term: string): Promise<void> {
  loading.value = true
  const response = await fetch(`/api/issues?q=${encodeURIComponent(term)}`)
  issues.value = (await response.json()) as Issue[]
  loading.value = false
}

watch(
  () => route.query.q,
  (q) => {
    void search(typeof q === 'string' ? q : '')
  },
  { immediate: true }
)
</script>

<template>
  <div>
    <p v-if="loading">Searching</p>
    <ul v-else>
      <li v-for="issue in issues" :key="issue.id">{{ issue.title }}</li>
    </ul>
  </div>
</template>

Filters as real links

IssueTabs.vue renders each tab as a link rather than a button, so the browser can open one in a new tab, the status bar shows where it goes, and the active tab is announced through aria-current. The :to object spreads route.query, so switching tabs keeps any other filter already in the URL. :to is the shorthand for v-bind:to, and both pass the object through unchanged; the string form to="/issues" would pass the literal text instead.

Example · vue
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'

const route = useRoute()

const tabs = [
  { label: 'Open', value: 'open' },
  { label: 'Closed', value: 'closed' }
]

const current = computed(() =>
  route.query.status === 'closed' ? 'closed' : 'open'
)
</script>

<template>
  <nav>
    <router-link
      v-for="tab in tabs"
      :key="tab.value"
      :to="{ path: route.path, query: { ...route.query, status: tab.value } }"
      :aria-current="tab.value === current ? 'page' : undefined"
    >
      {{ tab.label }}
    </router-link>
  </nav>
</template>

<style scoped>
nav {
  display: flex;
  gap: 0.75rem;
}
</style>

Discussion

  • Be the first to comment on this lesson.