skip to content

In Nuxt 4, how do you make a flight-status page's `useFetch` refetch when the airport filter changes, and what happens to its key?

level: middleimportance: should knowfreq 44%

answer

  1. snapshots versus refs
  2. what the auto-key hashes
  3. one key per filter value
  4. stale rows while pending

basics

~20 s

Pass the filter reactively, as query: { airport } or a getter URL, not a string built from airport.value. The auto-key hashes the URL and query, so each airport gets its own entry and a change starts a new fetch; watch: false opts out.

solid answer

~50 s

`useFetch` reacts only to what you hand it reactively. A URL string built from `airport.value` is fixed when the call runs, so nothing refetches, and `watch: [airport]` would only repeat the same request. Pass `query: { airport }` with `airport` a ref, or a getter such as `() => '/api/flights/' + airport.value`. The auto-key is `$f` plus a hash of the call site, URL, method, `baseURL`, query and body, so a new airport means a new key and a new entry: Nuxt seeds it with the previous airport's data if nothing is cached, fetches, and sets `status` to `pending`. The old entry loses its consumer, so its in-flight request is aborted. Render the loading state from `status`, since the old rows stay visible meanwhile. `watch: false` turns automatic refetching off, leaving `refresh()` for manual runs.

code

vue · 20 lines
vue
<script setup lang="ts">
const airport = ref('LHR')

// airport is part of the query, so it is part of the key
const { data: flights, status } = await useFetch('/api/flights', {
  query: { airport },
  default: () => [],
})
</script>

<template>
  <select v-model="airport">
    <option>LHR</option>
    <option>CDG</option>
  </select>
  <p v-if="status === 'pending'">Updating {{ airport }}</p>
  <ul :class="{ stale: status === 'pending' }">
    <li v-for="f in flights" :key="f.id">{{ f.number }} {{ f.state }}</li>
  </ul>
</template>

go deeper

for a junior

Recall that reactive input must reach useFetch as a ref or getter; a string built from .value is frozen at call time.

for a middle

Explain how the auto-key is derived from call site, URL and query, and why a new key means a new entry and a new request.

for a senior

Handle rapid filter changes: know which requests are aborted, why status must drive the stale-rows display, and why dedupe: 'defer' can pin an old result.

for a principal

Decide where filter state lives, in the URL query or in component state, so refetching, shareable links and back-button behaviour stay consistent across pages.

## Why a string URL never refetches `useFetch` can only react to input you hand it as a ref, a computed or a getter. Consider: ```ts const airport = ref('LHR') // Built once, when useFetch runs: always requests LHR const { data } = await useFetch(`/api/flights?airport=${airport.value}`) ``` The template literal is evaluated once and produces a plain string. Changing `airport` later changes nothing the composable can see, and adding `watch: [airport]` only re-runs the same LHR request, because `watch` re-executes the handler without rebuilding the URL. ## Three ways to make it reactive | Technique | Example | Key changes? | What triggers the refetch | |---|---|---|---| | Reactive fetch option | `query: { airport }` | yes, the query is hashed into the key | the option changing | | Getter or ref URL | `() => '/api/flights/' + airport.value` | yes, the URL is hashed into the key | the URL changing | | Extra watch source | `watch: [includeCancelled]` | no | the watched value changing | `useFetch` watches its fetch options, such as `query`, `body` and `headers`, automatically. `watch: false` opts out, after which only `refresh()` or `execute()` fetches. With `useAsyncData`, the equivalent is a reactive key such as `() => 'flights:' + airport.value`, or a static key plus `watch: [airport]`. ## What a key change does `useFetch`'s auto-key is `$f` followed by a hash of the call site, the URL, the method, `baseURL`, the query and the body. When the airport changes: 1. The key becomes a new string, so Nuxt looks up or creates a **new entry** for it, asking `getCachedData` first with cause `'initial'`. 2. If nothing is cached, the new entry is **seeded with the previous airport's data**, so the list does not blank out. 3. The old entry loses this consumer. If nothing else uses it, Nuxt **aborts its in-flight request** and, by default, purges it. 4. The new entry fetches, and `status` reads `'pending'` until the response lands. 5. Nuxt guards against the key watcher and the options watcher both firing, so one change produces one run. Step 2 is why `status` must drive the UI: for a moment the page shows LHR departures under a CDG heading. Dim the list or show *Updating* while `status === 'pending'`. ## Rapid changes and `dedupe` A user clicking through five airports in a second creates five keys. Each new key strands the previous entry, whose request is aborted, so only the last airport's result is written. With a static key and `watch`, all five runs share one entry, and the `dedupe` option decides what a run does while another is in flight: - `'cancel'` (the default) aborts the in-flight request through its `AbortSignal` and starts again, so the latest filter wins. - `'defer'` returns the in-flight promise and **starts nothing**, so the result can belong to an older airport with no follow-up fetch. For filters, keep `'cancel'`, and in `useAsyncData` pass the handler's `signal` to `$fetch` so the abort reaches the network. ## `immediate: false` and key changes in Nuxt 4 With `immediate: false`, a key change fetches only if the entry had already fetched, or was fetching. Nuxt 3's `useFetch` fetched on every key change; Nuxt 4 aligned it with `useAsyncData`, and `experimental.alwaysRunFetchOnKeyChange` restores the old behaviour. A search panel that should stay empty until the first search can rely on this and call `execute()` for that first search. ## Checklist - Pass refs or getters, never `.value` snapshots. - Put filter values in `query`, so they are part of both the key and the request. - Render loading from `status`, not from `data` being empty. - Keep `dedupe: 'cancel'` for filter-driven refetches. - Use `watch: false` only when a button, not every keystroke, should trigger the fetch. - For a text input, debounce the ref you pass in; every distinct value is a new key and a new request. - Remember that `refresh()` re-runs the current key only; it never brings back an airport the user has left. In an interview, the strongest answer names the mechanism, not just the fix: reactive input changes the key, a new key is a new entry, and the old entry is released.

  • Why does adding `watch: [airport]` not fix a URL built from `airport.value`?
    The `watch` option only re-runs the handler; it does not rebuild the URL. A string built from `airport.value` is fixed when `useFetch` is called, so every re-run requests the original airport. Build the URL with a getter, or pass `query: { airport }`, so the current value is read on each run.
  • A user clicks through five airports quickly. Which results end up in `data`?
    Only the last one. With `query: { airport }`, each airport is its own key; when the key moves on, the old entry loses its only consumer, so Nuxt aborts its request and ignores any late answer. With a static `useAsyncData` key and `watch: [airport]`, the default `dedupe: 'cancel'` aborts each older run; `'defer'` would instead hand back the in-flight promise and never fetch the newest airport.

saying these in an interview costs you the question

  • A URL string built from airport.value refetches whenever airport changes.
  • useFetch keys depend only on the URL, so query changes reuse one entry.
  • watch: false stops the initial request as well as the refetches.
  • dedupe: 'defer' is safer for filters because it never cancels anything.
  • Changing the filter always resets data to undefined until the response arrives.