Fetching data in a component
Load a list with fetch() in onMounted(), show loading, error and data, check res.ok before res.json(), and abort the request nobody is waiting for.
What you'll learn
When you finish, you can do these.- 1Load a list with fetch() when a component appears
- 2Check res.ok so a 404 or 503 shows a clear error
- 3Show a loading line and an error without hiding the list
- 4Cancel a request nobody waits for with AbortController
A team page lists the people on a team, and that list lives on a server, the computer that keeps the site's data. The browser has to ask for it, wait, and show something sensible the whole time: a loading line, then the names, or an error when the server is down. When the visitor leaves before the answer arrives, the request should stop as well.
The Code tab holds that page: UserList.vue asks the server for the names, and App.vue shows the list behind a Show the team checkbox. The request is written by hand. Later in this track, libraries do this work for you. By the end you can read every line and add a Retry button. You need one idea from Watchers, the final lesson of the Beginner track: a side effect, work that reaches outside the component, such as saving a value or calling a server.
Three states and one request
A request has a life, and each stage needs state the template can read. UserList.vue mounts (Vue puts it on the page) with an empty list, asks the server, and ends with data or an error. If it unmounts (leaves the page) first, the request is cancelled.
- Mount
- Loading
- Responsecheck res.ok
- Data or Error
- Unmountabort
That state is three refs, boxes that hold a value and tell Vue when it changes: the list, a loading flag and an error message.
interface User { id: number; name: string; city: string }
const users = ref<User[]>([])
const loading = ref(false)
const error = ref('')
User describes one person: an id, a name and a city. users starts as an empty list of them, loading as false and error as empty text, so nothing shows yet.
The request, a side effect, runs when the list appears and on each click of a Reload button, so it lives in a function, load(). fetch(url), the browser's function for asking a server, returns a promise, a value that arrives later: here a Response, the answer, named res. await waits for it, and res.json() reads the answer's body as JSON, the text format the data comes in.
Both steps sit inside try: a failure at either one jumps to catch, and finally runs either way, turning the loading line off:
async function load() {
loading.value = true
error.value = ''
try {
const res = await fetch('/api/users', { signal })
if (!res.ok) throw new Error(`Request failed with ${res.status}`)
users.value = await res.json()
} catch (e) {
error.value = (e as Error).message
} finally {
loading.value = false
}
}
onMounted(load)
onMounted(load) asks Vue to call load once the component is on the page. The { signal } is for cancelling, covered in Abort what nobody is waiting for.
The res.ok line matters. A promise resolves when its work succeeds and rejects when it fails, and fetch() rejects only when no answer came at all, such as a lost connection. Every answer carries a status number, and a 404 (not found) or a 503 (service unavailable) is still an answer: the promise resolves, and res.ok, the answer's success flag, is false. Throwing there sends a readable error, Request failed with 503, to catch.
A template that keeps the old list
The template turns the three refs into what the visitor sees:
<button @click="load">Reload</button>
<p v-if="loading">Loading…</p>
<p v-if="error">{{ error }}</p>
<ul>
<li v-for="u in users" :key="u.id">{{ u.name }} · {{ u.city }}</li>
</ul>
The loading line shows only while loading is true, and the error only while error holds a message. The <ul> has no v-if, so a click on Reload keeps the names on screen, with Loading… above them, until the new answer arrives.
The preview has no server of its own, so fakeServer.ts stands in for one. It answers /api/ requests after 0.8 seconds with Asha, Ben and Chen, fails every fourth one with a 503, and counts the requests on a line under the list. Its answers are real Response objects, so res.ok and res.json() work as with a real server.
Click Reload three times. The page sent the first request when it opened, so the third click sends the fourth: it shows Request failed with 503, the names stay, and the next click loads them again.
Abort what nobody is waiting for
People leave pages while requests run, and they click Reload twice. An AbortController, the browser's tool for cancelling, handles both: pass its signal to fetch(), and call abort() when nobody wants the answer. UserList.vue makes one controller per request and aborts the previous one first, so a slow old answer never lands after a newer one.
let controller: AbortController | undefined
async function load() {
controller?.abort()
controller = new AbortController()
const { signal } = controller
// …
}
onUnmounted(() => controller?.abort())
In controller?.abort(), the ?. skips the call before the first request, while controller is still undefined. Vue runs the function given to onUnmounted() when the component leaves the page, so unticking Show the team aborts a running request too.
An aborted fetch() rejects with an error whose name is AbortError; in Chrome its message reads signal is aborted without reason. It lands in the same catch as a real failure, so the catch checks the name first and returns without touching error:
} catch (e) {
if ((e as Error).name === 'AbortError') return
error.value = (e as Error).message
} finally {
if (!signal.aborted) loading.value = false
}
The if (!signal.aborted) in finally matters too. A second click aborts the first request, whose finally still runs: without the guard, it would switch Loading… off while the second request is still running.
In Nuxt, a framework built on Vue, useFetch() replaces all of this: it runs the request on the server and sends the result with the page (the Nuxt track's Data fetching lesson). In plain Vue, the next lesson, A reusable fetch composable, turns load() and its refs into a function any component can reuse.
What goes wrong
The first mistake skips the res.ok check. With a typo in the URL, /api/team for /api/users, the server answers 404 with an HTML page, and res.json() tries to read it as JSON:
const res = await fetch('/api/team', { signal })
users.value = await res.json() // the 404 page is HTML, not JSON
The page shows Failed to execute 'json' on 'Response': Unexpected token '<', "<!doctype "... is not valid JSON. The message never says the request failed, or with which status. Checking res.ok first gives Request failed with 404.
const res = await fetch('/api/team', { signal })
if (!res.ok) throw new Error(`Request failed with ${res.status}`)
users.value = await res.json()
The second mistake treats an abort as a failure, with a catch that records every error:
} catch (e) {
error.value = (e as Error).message
}
Click Reload, then again while Loading… shows. The second click aborts the first request, its catch runs, and the page shows signal is aborted without reason as an error, though the second request succeeds and the list is fine. Skip the AbortError by name.
} catch (e) {
if ((e as Error).name === 'AbortError') return
error.value = (e as Error).message
}
The aborted request now leaves no trace, and the list loads with no error line.
What to remember
- A request needs three refs: the data, a loading flag and an error message. Start it from
onMounted(), with the request intry, the message incatchand the flag infinally. fetch()resolves for every answer the server sends, including a 404 or a 503. Checkres.okand throw before you callres.json().- Give the loading line and the error a
v-ifand the list none, so a reload keeps the old names on screen. - Keep one
AbortControllerper request: abort it when a newer request starts and inonUnmounted(). SkipAbortErrorincatch, and leave the loading flag alone when the signal is aborted. - In Nuxt, use
useFetch()instead: it fetches on the server.
Try it
Open the Code tab and change the starter code. Each task says what you should see.- Add a Retry button to the error line:
<p v-if="error">{{ error }} <button @click="load">Retry</button></p>. Click Reload until the fourth request showsRequest failed with 503, then click Retry. The error line goes away, Loading… shows above the names for a moment, and the counter still says 1 failed. - Click Reload, and within the 0.8 seconds untick Show the team. The aborted count goes up by one:
onUnmountedaborted the request, and the catch ignored theAbortError. Tick the box again, and a fresh list loads. - Change the URL to
/api/teamand delete theres.okline. The page shows theUnexpected token '<'error from What goes wrong, because the 404 page is HTML. Put theres.okline back: the message becomesRequest failed with 404.