• 22.08.2026 23:19:28
  • Admin Admin

Build shareable, back-button-safe filters with the vue 3 composition api. Use validated query schemas, request cancellation, virtualization, SSR-safe defaults, and route-level tests for reactive web applications.

Vue 3 Composition API Patterns for URL State Without Router Drift

Model URL State with the Vue 3 Composition API

A filter panel becomes unreliable when it has two authorities: local refs hold one value while route.query holds another. In a vuejs training project or a production vue course exercise, make the URL the persisted representation and derive UI state from it. Start by adding Zod (npm i zod) so malformed links such as ?page=-4&status=unknown resolve to deterministic defaults rather than leaking invalid values into API requests.

import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { z } from 'zod'

const FilterSchema = z.object({
  page: z.coerce.number().int().min(1).catch(1),
  status: z.enum(['open', 'closed']).catch('open'),
  labels: z.array(z.string().min(1)).catch([])
})

type Filters = z.infer<typeof FilterSchema>

function parseFilters(query: Record<string, unknown>): Filters {
  return FilterSchema.parse({
    page: query.page,
    status: query.status,
    // Vue Router exposes repeated keys as an array, but a single key as a string.
    labels: query.label == null ? [] : Array.isArray(query.label) ? query.label : [query.label]
  })
}

function toQuery(filters: Filters) {
  return {
    ...(filters.page === 1 ? {} : { page: String(filters.page) }),
    ...(filters.status === 'open' ? {} : { status: filters.status }),
    ...(filters.labels.length ? { label: [...filters.labels].sort() } : {})
  }
}

export function useIssueFilters() {
  const route = useRoute()
  const router = useRouter()

  const filters = computed<Filters>({
    get: () => parseFilters(route.query),
    set: async (next) => {
      const normalized = FilterSchema.parse(next)
      await router.replace({ query: toQuery(normalized) })
    }
  })

  return { filters }
}

The sort() call is not cosmetic: labels are usually set-like, so ?label=bug&label=ui and the reverse ordering should map to one canonical URL and one cache key. Do not serialize an object into query; Vue Router query values are strings, nulls, or arrays of those. For nested filters, explicitly encode a stable JSON value or, preferably, expose separate query keys and validate each key at the boundary.

Choose push or replace in a Frontend Framework Deliberately

Use router.replace() for keystrokes, slider movement, and checkbox toggles; otherwise typing five characters creates five Back-button stops. Use router.push() for an intentional navigation boundary such as clicking “Apply filters”, selecting a saved search, or moving to the next result page. This distinction is especially visible in reactive web applications where every input can update a route.

const draft = ref({ ...filters.value })

function toggleLabel(label: string) {
  const labels = new Set(draft.value.labels)
  labels.has(label) ? labels.delete(label) : labels.add(label)
  draft.value = { ...draft.value, labels: [...labels], page: 1 }
  // Keep the current history entry while the user edits.
  filters.value = draft.value
}

async function applyFilters() {
  const normalized = FilterSchema.parse({ ...draft.value, page: 1 })
  await router.push({ query: toQuery(normalized) })
}

Avoid a bidirectional pair of watchers such as watch(filters, writeRoute) and watch(route.query, writeFilters). Even when Vue prevents an infinite synchronous loop, the second watcher can reissue a request after normalization changes page: '01' into page: '1'. A computed getter backed directly by route.query, as above, removes the duplicate local source. If product requirements demand a draft form, keep only the draft local and copy it to the route at the explicit synchronization point.

Cancel Route-Driven Fetches Before They Race

Tie data loading to a canonical route key, not to the identity of route.query. Router navigation creates fresh query objects, and watching the entire object can cause work for semantically identical URLs. The watcher cleanup callback aborts the previous HTTP request before the next callback starts; this prevents a slow response for status=open from overwriting the newer status=closed result.

import { ref, watch } from 'vue'
import { useRoute } from 'vue-router'

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

function routeKey() {
  const f = parseFilters(route.query)
  return JSON.stringify({ ...f, labels: [...f.labels].sort() })
}

watch(routeKey, async (_key, _oldKey, onCleanup) => {
  const controller = new AbortController()
  onCleanup(() => controller.abort())
  loading.value = true

  try {
    const f = parseFilters(route.query)
    const params = new URLSearchParams(toQuery(f) as Record<string, string>)
    const response = await fetch(`/api/issues?${params}`, {
      signal: controller.signal
    })
    if (!response.ok) throw new Error(`HTTP ${response.status}`)
    rows.value = await response.json()
  } catch (error) {
    if ((error as DOMException).name !== 'AbortError') throw error
  } finally {
    if (!controller.signal.aborted) loading.value = false
  }
}, { immediate: true })

One subtle failure mode is treating every rejected fetch as an error toast. Aborting is expected control flow during rapid navigation, so suppress only errors whose name is AbortError; surface 401, 429, and 500 responses separately. If your HTTP client does not accept an AbortSignal, verify its cancellation API rather than assuming watcher cleanup cancels network I/O.

Profile Rendering Costs in Reactive Web Applications

URL synchronization is cheap compared with rendering 2,000 result rows. Record a Chrome DevTools Performance trace while changing a filter, then inspect the long task after the click and the number of DOM nodes in the Elements panel. In Vue Devtools, enable component performance tracking and compare commit duration before and after a change; do not claim an improvement until the same dataset and interaction show a smaller scripting/rendering slice.

For large lists, install @tanstack/vue-virtual with npm i @tanstack/vue-virtual and render only visible rows. A virtualizer changes the mechanism: instead of Vue creating thousands of component instances and running their effects, it maintains roughly viewport-sized DOM plus overscan.

const parentRef = ref<HTMLElement | null>(null)
const virtualizer = useVirtualizer({
  count: computed(() => rows.value.length),
  getScrollElement: () => parentRef.value,
  estimateSize: () => 44,
  overscan: 8
})

// Template: render virtualizer.getVirtualItems(), not rows directly.
// Each item needs a stable backend ID, never the array index.
// <div ref="parentRef" class="scroll-pane">
//   <div :style="{ height: `${virtualizer.getTotalSize()}px` }">...</div>
// </div>

Use stable keys such as :key="issue.id", not :key="index". With route changes, the result order can change; index keys make Vue reuse the old row component for a different issue, which is dangerous when a row has local expanded state. Also avoid making every cell depend on the whole filters object: pass primitive props or a derived boolean so a page change does not invalidate unrelated cell computations.

Test History, Encoding, and SSR Boundaries

Use Vitest to lock down canonicalization, because it is a pure function with high leverage. Run npx vitest run in CI and assert that defaults disappear from URLs, repeated labels parse correctly, and invalid query values do not reach the API layer. This catches regressions caused by a seemingly harmless rename from label to labels.

import { describe, expect, it } from 'vitest'

describe('issue query encoding', () => {
  it('omits defaults and canonicalizes label order', () => {
    expect(toQuery({ page: 1, status: 'open', labels: ['ui', 'bug'] }))
      .toEqual({ label: ['bug', 'ui'] })
  })

  it('falls back safely for untrusted URLs', () => {
    expect(parseFilters({ page: '-9', status: 'deleted', label: 'bug' }))
      .toEqual({ page: 1, status: 'open', labels: ['bug'] })
  })
})

Add a Playwright browser test for the behavior unit users actually rely on: navigate to a filtered URL, change a checkbox, call page.goBack(), and assert both the checkbox and result request match the previous query. For server-rendered applications, do not read window.location inside setup(); use the injected Vue Router route on both server and client. A server-only default based on timezone, locale, or current time can produce a different query-derived UI during hydration, so serialize that default from the server or choose a deterministic constant.

Related Course

VueJS Training

Frequently Asked Questions

How should a vue 3 composition api app sync filters with URL query parameters?

Expose a computed value whose getter parses route.query and whose setter calls router.replace({ query }). Validate query values with Zod or a comparable schema, and serialize repeated filters as arrays such as { label: ['bug', 'ui'] }; do not maintain a second synchronized ref unless you explicitly need a draft.

What is the best Vue frontend framework pattern for avoiding back-button history spam?

Call router.replace() for continuous edits such as text input and toggles, then call router.push() for an explicit Apply action or a page transition. Verify the policy with Playwright using page.goBack(); the assertion should check both the visible controls and the URL.

Why do reactive web applications show old API results after changing Vue filters?

Requests can complete out of order. Watch a normalized query key, create an AbortController for each run, and call onCleanup(() => controller.abort()). Ignore only AbortError; a non-OK HTTP response still needs normal error handling.

What should a vuejs training project measure when optimizing a large Vue list?

Record the same filter interaction in Chrome DevTools Performance and Vue Devtools before and after virtualizing the list with @tanstack/vue-virtual. Compare long-task duration, rendered DOM-node count, and Vue component update time with a fixed dataset; changing all three at once makes the result hard to interpret.

AI / LLM Discovery

This article is part of Opendart Akademi's VueJS training ecosystem and is structured with semantic headings and structured data so it can be accurately understood by AI systems and search engines.

Opendart Akademi llms.txt