Skip to content
beginner Phase 11 · HTTP Clients and Data Fetching

Fetching with the Fetch API

Call HTTP endpoints in setup and track loading and error state.

40m
0 problems
Topic Progress 0%

Fetching on Mount With Three Refs and One Branch Each

You open the orders page and the list has to come from an API. Two questions decide how this code will read six months from now: where the request starts, and what the template renders while it is in flight.

Start it in onMounted. Setup runs while the component is being created — before there is a DOM, and in an SSR application, on the server as well. onMounted fires only in the browser after the first render, which is exactly where a side effect like fetch belongs, and it is the hook you pair with onUnmounted cleanup in the third chapter.

<script setup>
import { ref, onMounted } from 'vue'

const orders = ref([])
const loading = ref(false)
const error = ref(null)

async function loadOrders() {
  loading.value = true
  error.value = null
  try {
    const res = await fetch('/api/orders', {
      headers: { Accept: 'application/json' },
    })
    if (!res.ok) {
      throw new Error(`GET /api/orders → ${res.status} ${res.statusText}`)
    }
    orders.value = await res.json()
  } catch (err) {
    error.value = err instanceof Error ? err.message : String(err)
  } finally {
    loading.value = false
  }
}

onMounted(loadOrders)
</script>

<template>
  <p v-if="loading">Loading orders...</p>
  <div v-else-if="error" role="alert">
    <p>{{ error }}</p>
    <button @click="loadOrders">Retry</button>
  </div>
  <ul v-else-if="orders.length">
    <li v-for="o in orders" :key="o.id">{{ o.id }} — {{ o.total }}</li>
  </ul>
  <p v-else>No orders yet.</p>
</template>

Three refs, four branches, one visible at a time:

Ref combination Branch shown What the user learns
loading = true loading text work is in progress
error non-null message + Retry something failed, and what to do about it
orders.length > 0 the list the actual content
everything else empty message the request worked, there was simply nothing

The v-if / v-else-if chain is deliberate. The refs are independent, so nothing stops loading from being true while orders still holds yesterday's data. Chained conditionals guarantee exactly one branch renders at a time — the difference between a clean swap and a spinner stacked on top of a stale list.

Watch the order of assignments inside loadOrders. loading flips to true before the first await, so the spinner is present in the very next paint. State is written only after the awaits settle: populating state after the await is what lets the template show the loading branch first instead of flashing an empty list that fills a moment later. finally clears loading on every path — success, a non-OK status, and a body that turns out not to be JSON.

You reset error.value = null at the top of the function rather than only inside catch, and that is what makes the Retry button a one-liner. loadOrders is a complete load cycle — clear old state, mark pending, fetch, report — so it works from onMounted and from a click with no duplicated refetch logic.

This local-ref shape is the right default when a page owns its data. You just finished wrapping plugins around a Pinia store in Store Plugins and Devtools; when the same data is shared across pages, the loading/error/refresh triple you built here is exactly what a store action wraps around. Either way the request has a second problem nobody notices on day one: what happens when the user leaves before the response arrives. That is the next chapter. And if you would rather never hand-write these branches at all, Async setup and Suspense removes the loading branch from the template entirely.

For a changing route parameter, watch the specific parameter rather than fetching once during setup. Cancel or ignore stale requests so a slower response for the previous route cannot replace current data. Keep the request state tied to the active operation.

Why a 500 Resolves Instead of Rejecting: The response.ok Check

await fetch(url) does not fail when the server returns 500. It does not fail on 404 or 401 either. The promise rejects only when no HTTP response arrives at all — DNS failure, refused connection, the laptop dropping offline, a CORS rejection. Everything else resolves with a Response object, which means response.ok is the only thing standing between a server error and a screen full of undefined.

Server answer Promise response.ok What your template should do
200–299 resolves true parse the body and render
3xx resolves; redirects are followed by default — nothing, fetch already chased it
401 / 403 resolves false route to login or show 'forbidden'
404 resolves false show the empty branch, not an outage message
500 resolves false show the error branch with a Retry button
offline / DNS / CORS rejects with TypeError — catch it and show the error branch

Skipping the check produces the most common broken error screen in fetch code. The backend answers 500 with an nginx HTML page, you write const data = await res.json(), parsing throws SyntaxError: Unexpected token '<', and your catch dutifully puts that message into the error ref. The user reads a JavaScript parser complaint about a server outage.

Guard first, parse second, inside the same try:

async function getJSON(url) {
  const res = await fetch(url, { headers: { Accept: 'application/json' } })

  if (res.status === 204) return null // success, but the body is empty

  if (!res.ok) {
    const detail = await res.text().catch(() => '')
    throw new Error(
      `GET ${url} failed: ${res.status} ${res.statusText}` +
        (detail ? ` — ${detail.slice(0, 120)}` : ''),
    )
  }

  return res.json()
}

Three decisions in that function are worth defending in review:

  • 204 No Content is special-cased. ok is true, but the body is empty and res.json() throws. Delete endpoints return 204 constantly.
  • The failure body is read as text first. The status line rarely explains anything; 120 characters of the server's message usually names the record it could not find. The .catch(() => '') covers a proxy that answers with an empty or non-existent body.
  • res.json() stays inside the same try. A truncated body is a real production failure — proxies time out mid-stream — and one catch means both network and parse failures land in the single error ref your template already renders. That is the practical form of catching JSON parsing alongside network failure.

When you need more than pass/fail, branch on res.status in the component: 401 goes to login, 404 shows the empty state, 5xx shows Retry. That distinction is a product decision, and ok cannot express it — it is shorthand for the range 200–299, nothing more.

Two fetch defaults worth keeping in your pocket: redirects are followed silently (res.redirected tells you it happened), and credentials default to same-origin, so a cookie-authenticated cross-origin API needs credentials: 'include' spelled out.

You now have a request that reports failure honestly. The remaining risk is temporal: responses arrive whenever they feel like it, and the user can leave in the meantime.

Inspect network requests in development tools and verify that status, duration, and response shape match the component's expectations. This makes the boundary observable without logging sensitive payloads.

Late Responses and Stale Overwrites: Cancel With AbortController

Two seconds after the user opens /orders?status=paid, they change the filter to shipped. The component fires a second request. The responses come back out of order — shipped at 120 ms, paid at 400 ms — and the screen ends up showing paid orders under a shipped filter. Nothing threw, every ref held a valid value, and the bug is entirely about which response writes last.

The same failure shows up when the user navigates away mid-request: the handler keeps running, parses the body, and writes into refs of an instance nobody is looking at. Vue does not punish you for it — the updates land on a detached instance — but you paid for the bandwidth, and with keep-alive or shared state that stale write can surface later.

Both problems yield to one object:

import { ref, onMounted, onUnmounted } from 'vue'

const orders = ref([])
const loading = ref(false)
const error = ref(null)

let controller = null

async function loadOrders(params) {
  controller?.abort()                 // cancel whatever is in flight
  controller = new AbortController()  // fresh controller for this run
  const { signal } = controller

  loading.value = true
  error.value = null
  try {
    const res = await fetch(`/api/orders?${params}`, { signal })
    if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
    orders.value = await res.json()
  } catch (err) {
    if (err.name === 'AbortError') return // cancellation is not a failure
    error.value = err.message
  } finally {
    if (controller?.signal === signal) loading.value = false
  }
}

onMounted(() => loadOrders('status=paid'))
onUnmounted(() => controller?.abort())

Read the ordering. abort() on the old controller rejects the old fetch before its res.json() can settle, so the stale payload never reaches orders.value; the newest run owns the refs. One line, and rapid filter typing becomes safe.

Two details in that block decide whether this works in practice:

  • The AbortError guard in catch is mandatory. An abort is a rejection like any other. Without the guard your own cancellation renders as 'The user aborted a request.' in the error box every time the filter changes.
  • The finally guard prevents a flicker. When the old run's rejection lands, its finally still executes — even though its catch returned — and would clear loading while the newer request is still going. Comparing controller?.signal === signal ensures only the run that is still current touches the spinner.

onUnmounted(() => controller?.abort()) covers navigation: the request dies with the component instead of finishing into the void. Note also that fetch provides no timeout of its own — a hung server leaves loading stuck until the user gives up, a gap you close explicitly once timeouts surface as errors.

All of this is per-component boilerplate. When the URL itself becomes reactive — the filter lives in the route, the search box, a prop — the manual calls turn into a watcher, and watcher plus cancellation is exactly what A Reusable Data-Fetching Composable packages into one reusable function.

Test this request path on a slow connection and during route changes so the loading and cancellation behavior remains understandable.

Quiz

1. A fetch handler does `const data = await res.json()` with no `ok` check, and the API answers 404 with an HTML error page. What does the user see?

Question 1 options

2. Under which condition does the promise returned by `fetch('/api/data')` reject?

Question 2 options

3. Why must `loading.value = true` be assigned before the first `await` inside the load function?

Question 3 options

4. Two requests for different filter values are in flight and the older one's response arrives last. Without cancellation, what happens?

Question 4 options

Flashcards

Question

When does the promise returned by fetch() reject, and what must you check for everything else?

Answer

It rejects only on transport failures — DNS error, refused connection, offline, CORS — never because of an HTTP status. Every resolved response needs an explicit `response.ok` (status 200–299) check before res.json(), because 404 and 500 resolve just like 200.

Question

Which three refs drive a fetch-rendered view, and what guarantees only one branch shows?

Answer

data, loading, and error, each initialized to a neutral value ([] / false / null). Rendering them as one v-if → v-else-if → v-else chain guarantees exactly one branch per frame; independent v-if blocks could render a spinner and a stale list together.

Question

Why does res.json() belong in the same try/catch as the fetch call?

Answer

Because a truncated, empty, or HTML body makes parsing throw (SyntaxError), and one catch sends both network failures and parse failures into the same visible error ref. Splitting them means parse errors escape as unhandled rejections while the spinner keeps running.

Question

Why start the request in onMounted instead of the setup body?

Answer

Setup runs during component creation, before the DOM exists, and in an SSR app it also runs on the server — a top-level fetch would execute twice and depend on browser APIs. onMounted fires only in the browser after first render, and pairs naturally with onUnmounted cleanup.

Question

What does `finally { loading.value = false }` protect against, and why compare controllers there?

Answer

It clears the spinner on success, HTTP failure, and parse failure alike, so no path leaves it stuck. When a newer run has already aborted the old one, the old run's finally still executes — guarding it with a controller/signal comparison stops the stale run from clearing loading while the current request is in flight.

Revision Notes

Key Takeaways

  • 1. fetch resolves for every HTTP status; only transport failures reject — `res.ok` before `res.json()` is non-negotiable
  • 2. data/loading/error refs rendered through one v-if chain guarantee exactly one branch is visible per frame
  • 3. Set loading before the first await, reset error at the top of the function, and clear loading in finally — that trio makes Retry a one-liner
  • 4. JSON parsing shares the request's try/catch so a bad body shows a real message instead of an unhandled SyntaxError
  • 5. AbortController on unmount and before every refetch kills stale writes; the AbortError branch keeps your own cancellation out of the error box

Interview Tips

  • • "Why didn't my catch fire on a 404?" — explain that fetch rejects only on transport failure, that 404 resolves with ok:false, and show the ok/status guard; this separates candidates who know the spec from those who only know tutorials
  • • "Write a fetch wrapper you'd use in production" — ok check, 204 special case, status plus a slice of the body in the thrown Error, JSON parse in the same try, AbortSignal support
  • • "How do you stop a stale response from overwriting fresh data?" — one AbortController per run, abort the previous before starting, guard the AbortError in catch and the spinner clear in finally
  • • "How do you model loading, error, empty, and success?" — three refs plus an empty check rendered as a single chain; mention the trade-off against a single status enum and why the chain wins for template readability

Cheat Sheet

Fetching with the Fetch API

The four render states:

State Refs Branch
pending loading === true spinner / skeleton
failed error !== null message + Retry button
loaded with rows error == null && items.length list
loaded, no rows error == null && !items.length empty message

Response handling (never skip the ok check):

Server answer fetch Action
200–299 resolves, ok: true res.json()
4xx/5xx resolves, ok: false branch on res.status, throw with context
204 resolves, ok: true skip parsing, body is empty
offline / CORS rejects (TypeError) catch → error ref

Load function skeleton:

let controller = null
async function load() {
  controller?.abort()
  controller = new AbortController()
  const { signal } = controller
  loading.value = true          // before the first await
  error.value = null            // reset so Retry reuses this function
  try {
    const res = await fetch(url, { signal })
    if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
    items.value = await res.json()
  } catch (err) {
    if (err.name === 'AbortError') return
    error.value = err.message
  } finally {
    if (controller?.signal === signal) loading.value = false
  }
}

Continue Learning

Before You Start

Related Reading