skip to content

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?

level: seniorimportance: should knowfreq 38%

answer

  1. two defaults moved in 4.0
  2. only .value is tracked
  3. null became undefined
  4. replace, do not mutate

basics

~20 s

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

Nuxt 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
vue
<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

for a junior

Recall that in Nuxt 4 data starts as undefined and is a shallowRef, so you replace it rather than mutating inside it.

for a middle

Explain why a nested mutation on a shallowRef triggers nothing, and how replacing .value or deep: true fixes it.

for a senior

Audit an upgrade for silent breakage: null checks, in-place mutations, pending on non-immediate calls, boolean dedupe, and clear() now resetting to default.

for a principal

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.