After upgrading a flight-status page from Nuxt 3 to Nuxt 4, why does `data.value.flights.push()` stop updating the list and `data.value === null` never match?
answer
- two defaults moved in 4.0
- only .value is tracked
- null became undefined
- replace, do not mutate
basics
~20 sNuxt 4 returns data as a shallowRef, so mutating nested properties triggers no update; replace the value or pass deep: true. data and error now start as undefined instead of null, so null checks never match; test status or supply a default.
solid answer
~40 sNuxt 4 changed two defaults under the same code. First, `data` from `useFetch` and `useAsyncData` is a `shallowRef`: assigning `data.value` triggers updates, but `data.value.flights.push(flight)` changes a plain array nothing tracks, so the list does not re-render. Replace the value instead, refetch after writes, or opt in with `deep: true` per call; the global `experimental.defaults.useAsyncData.deep` exists but is not recommended. Second, `data` and `error` now start as `undefined`, not `null`, so `=== null` checks take the wrong branch; drive the template from `status` or give the call a `default`. While auditing, also check `pending`, now `status === 'pending'` and so `false` before a non-immediate call runs, and `refresh({ dedupe: true })`, which was removed in favour of `'cancel'` and `'defer'`.
code
vue · 19 lines<script setup lang="ts">
type Flight = { id: string, number: string, state: string }
const { data, status } = await useFetch('/api/flights/board', {
default: () => ({ flights: [] as Flight[] }),
})
function addFlight (flight: Flight) {
// Nuxt 4: data is a shallowRef, so replace instead of mutating
data.value = { ...data.value, flights: [...data.value.flights, flight] }
}
</script>
<template>
<p v-if="status === 'error'">Board unavailable</p>
<ul v-else>
<li v-for="f in data.flights" :key="f.id">{{ f.number }} {{ f.state }}</li>
</ul>
</template>go deeper
Recall that in Nuxt 4 data starts as undefined and is a shallowRef, so you replace it rather than mutating inside it.
Explain why a nested mutation on a shallowRef triggers nothing, and how replacing .value or deep: true fixes it.
Audit an upgrade for silent breakage: null checks, in-place mutations, pending on non-immediate calls, boolean dedupe, and clear() now resetting to default.
Plan the upgrade path: per-call fixes over global compatibility flags, codemods where they exist, and tests that exercise refetch and mutation paths.
## Two defaults changed under the same code Nuxt 4 (4.0.0, July 2025) changed how `useFetch` and `useAsyncData` hand you data. Nuxt 3 reached end of life on 31 July 2026, so upgraded pages meet these changes now. Two of them explain the symptoms: 1. `data` is a **`shallowRef`** instead of a deep `ref`. 2. `data` and `error` start as **`undefined`** instead of `null`. ## Why `push` stops updating the list A `shallowRef` tracks only its `.value`. Replacing `.value` notifies every dependent; changing something **inside** the value does not, because Vue never made the nested objects reactive. - `data.value = newBoard` updates the page. - `data.value.flights.push(flight)` mutates a plain array that nothing tracks, so the list does not re-render until something unrelated forces a render, which makes the bug look intermittent. - `data.value.flights[0].state = 'departed'` is equally silent. Nuxt made this the default for performance: a deep ref wraps every nested object in a reactive proxy, which is expensive for large payloads such as a full departures board, and fetched data is usually replaced wholesale on the next refetch anyway. ### Fixes, from most to least preferred 1. **Replace, do not mutate**: `data.value = { ...data.value, flights: [...data.value.flights, flight] }`. 2. **Refetch** after a write with `refresh()`, so the server stays the source of truth. 3. **Opt in per call** with `deep: true` where editing in place really is the model. 4. **Opt in globally** with `experimental.defaults.useAsyncData.deep: true`, which the migration guide offers but does not recommend. ## Why `=== null` never matches In Nuxt 3, `data` started as `null`, yet `clearNuxtData` reset it to `undefined`; Nuxt 4 made `data` and `error` both start as `undefined` for consistency. Code such as `v-if="data === null"` or `if (error.value !== null)` now takes the wrong branch. Better options: - test `status` (`'idle' | 'pending' | 'success' | 'error'`) for loading and failure; - give the call a `default` factory, for example `default: () => ({ flights: [] })`, so templates never meet `undefined`; - use a loose `data.value == null` check only as a stopgap. A custom `default` also gained weight: `clear()` and `clearNuxtData` now reset `data` to it instead of unsetting it. ## Other data-fetching changes to audit | Nuxt 3 | Nuxt 4 | |---|---| | `data` is a deep `ref` | `shallowRef`; `deep: true` opts in | | `data` and `error` start as `null` | start as `undefined` | | `pending` true before a non-immediate call runs | `pending` is `status === 'pending'` | | `refresh({ dedupe: true })` or `false` | only `'cancel'` or `'defer'` | | same-key calls could hold separate refs | one shared entry per key | | `getCachedData` only on the initial fetch | on every fetch, with `ctx.cause` | `experimental.pendingWhenIdle: true` brings back the old `pending` reading while you migrate. The boolean `dedupe` values were removed because they had become opposites: `true` on `refresh` meant cancel, while `dedupe: true` as a composable option had meant not starting a new request. ## Where the time goes in an upgrade - Search for `=== null` and `!== null` against `data` and `error`. - Search for in-place mutation of fetched data: `push`, `splice`, property assignment. - Check templates that read `pending` on non-immediate calls. - Run the codemods the migration guide lists, then review what they could not see. ## Designing templates that survive both changes The most robust pattern treats fetched data as a value you read and replace, never a structure you edit: - give every list-shaped call a `default` factory, so the template has one shape to render and `clear()` returns to it; - branch on `status` for the loading and error states instead of inspecting `data`; - route writes through `$fetch`, then either `refresh()` or assign a new object to `data.value`; - reserve `deep: true` for genuinely editable documents, such as a form bound to fetched data, and say so in a comment. A page written this way behaves the same whether `data` is shallow or deep and whatever its starting value, which also makes the next major upgrade cheaper. The interview-worthy point is that neither change throws: both fail silently, one as a list that stops updating and one as a branch that never runs, so a Nuxt 4 upgrade needs a deliberate audit rather than a green build.
- Why did Nuxt make `data` shallow by default?A deep ref makes Vue wrap every nested object and array in a reactive proxy, which costs time and memory on large payloads such as a full departures board. Fetched data is usually replaced wholesale on refetch, and replacing `.value` still triggers updates. `deep: true` per call, or `experimental.defaults.useAsyncData.deep` globally, restores deep reactivity where you really mutate in place.
- What changed for `pending` and `refresh` in Nuxt 4?`pending` is now computed from `status === 'pending'`, so with `immediate: false` it stays `false` until the first request starts; `experimental.pendingWhenIdle: true` restores the old reading temporarily. `refresh({ dedupe: true })` and `false` were removed: use `'cancel'` for the old `true` and `'defer'` for the old `false`. `clear()` and `clearNuxtData` now reset `data` to your `default`.
saying these in an interview costs you the question
- data from useFetch is deeply reactive, so pushing into an array re-renders the list.
- An unfinished useFetch leaves data as null until the request resolves.
- refresh({ dedupe: true }) is still how you cancel an older request.
- pending stays true until the first request finishes, even with immediate: false.
- Setting deep: true globally is the recommended Nuxt 4 migration step.