Fetching Data

Where a request belongs in a component's lifecycle, the three states every request has, and how to abort one on unmount.

SyntaxonMounted(async () => { const res = await fetch(url, { signal: controller.signal }) })

Vue has no data layer of its own. You use fetch, and you decide when it runs.

Where the request starts

For a request that runs once, put it in onMounted, the hook Vue calls after the component's DOM is in the page. For a request that depends on a value which changes — a route parameter, a selected id — put it in a watch with { immediate: true }, so it runs now and again on every change.

Three states, always

Every request is pending, then either a success or a failure. Hold all three: a ref for the data, a ref for the error, a boolean for pending. Set pending before the await and clear it in finally, so a thrown error cannot leave a spinner on screen forever.

Aborting

A component can unmount while a request is in flight. Create an AbortController, pass its signal to fetch, and call controller.abort() in onUnmounted. An aborted fetch rejects with a DOMException whose name is AbortError. Check for it and return, rather than showing the user an error they did not cause.

The trap

fetch resolves for 404 and 500. Only a request that never completed rejects. Check res.ok and throw, or an error page gets parsed as your data.

Example

Example · vue
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'

interface User {
  id: number
  name: string
  email: string
}

const user = ref<User | null>(null)
const error = ref<Error | null>(null)
const pending = ref(true)
const controller = new AbortController()

onMounted(async () => {
  try {
    const res = await fetch('/api/users/1', { signal: controller.signal })
    if (!res.ok) throw new Error(`HTTP ${res.status}`)
    user.value = (await res.json()) as User
  } catch (err) {
    if (err instanceof DOMException && err.name === 'AbortError') return
    error.value = err instanceof Error ? err : new Error('Request failed')
  } finally {
    pending.value = false
  }
})

onUnmounted(() => {
  controller.abort()
})
</script>

<template>
  <p v-if="pending">Loading the profile</p>
  <p v-else-if="error">Could not load the profile: {{ error.message }}</p>
  <p v-else-if="user">{{ user.name }} &lt;{{ user.email }}&gt;</p>
</template>

<style scoped>
p {
  margin: 0;
}
</style>

When to use it

  • Load a user's profile once when a detail page mounts.
  • Reload a side panel whenever the selected record id changes.
  • Cancel an in-flight search request when the user navigates away mid-request.
  • Show a real error message when an API returns 500 instead of rendering an empty page.

More examples

Refetching when a prop changes

onMounted runs once, so it cannot serve a panel whose id changes. A watch with { immediate: true } runs the same code on mount and again on every change of props.orderId. The callback's third argument, onCleanup, registers the abort for the previous request: Vue calls it just before the watcher re-runs, which is what stops an old response from overwriting a newer one. Reach for this shape whenever the request depends on something reactive.

Example · vue
<script setup lang="ts">
import { ref, watch } from 'vue'

interface Order {
  id: number
  total: number
}

const props = defineProps<{ orderId: number }>()

const order = ref<Order | null>(null)
const pending = ref(false)

watch(
  () => props.orderId,
  async (id, _previousId, onCleanup) => {
    const controller = new AbortController()
    onCleanup(() => controller.abort())
    pending.value = true
    try {
      const res = await fetch(`/api/orders/${id}`, { signal: controller.signal })
      if (!res.ok) throw new Error(`HTTP ${res.status}`)
      order.value = (await res.json()) as Order
    } catch (err) {
      if (err instanceof DOMException && err.name === 'AbortError') return
      order.value = null
    } finally {
      pending.value = false
    }
  },
  { immediate: true },
)
</script>

<template>
  <p v-if="pending">Loading order {{ props.orderId }}</p>
  <p v-else-if="order">Order {{ order.id }}: {{ order.total }}</p>
  <p v-else>No such order.</p>
</template>

<style scoped>
p {
  font-variant-numeric: tabular-nums;
}
</style>

One helper that throws on a failed response

The res.ok check and the AbortError check are the same two lines in every component, so they belong in one module. Every call site then gets a promise that rejects for both a transport failure and an HTTP error status, which is the behaviour the rest of this stage assumes. Put it in src/api/ early: the fetch composable and the query library in the next lessons both call something like this.

Example · ts
// src/api/http.ts
export async function getJson<T>(url: string, signal?: AbortSignal): Promise<T> {
  const res = await fetch(url, {
    signal,
    headers: { Accept: 'application/json' },
  })
  if (!res.ok) {
    throw new Error(`${res.status} ${res.statusText}`)
  }
  return (await res.json()) as T
}

export function isAbort(err: unknown): boolean {
  return err instanceof DOMException && err.name === 'AbortError'
}

// On a 404 response, getJson rejects. Logging err.message prints:
// Output: 404 Not Found

// isAbort(new DOMException('aborted', 'AbortError'))
// Output: true

Two requests the page needs together

Two awaits in sequence make the page wait for the sum of both requests. Promise.all starts them together, so the wait is the slower one. One AbortController passed to both cancels both at once on unmount. Note the single :key on the v-for — it is the shorthand for v-bind:key and the two are identical — and that v-if lives on a wrapper, never on the same element as the v-for.

Example · vue
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'

interface Profile {
  name: string
}

interface Invoice {
  id: number
  amount: number
}

const profile = ref<Profile | null>(null)
const invoices = ref<Invoice[]>([])
const failed = ref(false)
const pending = ref(true)
const controller = new AbortController()

onMounted(async () => {
  const options = { signal: controller.signal }
  try {
    const [profileRes, invoiceRes] = await Promise.all([
      fetch('/api/profile', options),
      fetch('/api/invoices', options),
    ])
    if (!profileRes.ok || !invoiceRes.ok) throw new Error('Request failed')
    profile.value = (await profileRes.json()) as Profile
    invoices.value = (await invoiceRes.json()) as Invoice[]
  } catch {
    failed.value = true
  } finally {
    pending.value = false
  }
})

onUnmounted(() => controller.abort())
</script>

<template>
  <p v-if="pending">Loading</p>
  <p v-else-if="failed">Could not load the account.</p>
  <div v-else>
    <h2>{{ profile?.name }}</h2>
    <ul>
      <li v-for="invoice in invoices" :key="invoice.id">{{ invoice.amount }}</li>
    </ul>
  </div>
</template>

<style scoped>
ul {
  padding-left: 1rem;
}
</style>

Discussion

  • Be the first to comment on this lesson.