Skip to the solution
MediumFrontend10 min read

How do you fetch data in a Vue component (loading, errors, cancellation)?

496 views

The problem

Question presented to candidate: "A product page loads the product for the id in the URL. Users click quickly between products and sometimes see the wrong one, and a 404 shows an empty page with no message. Show how you would fetch data in a Vue component, with loading and error states and proper cancellation."

What a strong answer should cover:

  • Three pieces of state: data, loading, error, rendered with v-if / v-else-if so every state has a UI.
  • Start fetching in setup (or a watch with immediate: true on the input), not only in onMounted, and re-fetch when the input (route param, prop) changes.
  • Race conditions: an older, slower response can arrive last and overwrite the newer one. Cancel it with an AbortController registered via onWatcherCleanup (Vue 3.5+) or the watcher's onCleanup argument; ignore AbortError.
  • fetch only rejects on network failure: check res.ok and throw for 4xx/5xx yourself.
  • Extract a composable (useFetch) or use a data-fetching library for caching and dedupe; mention Suspense/async setup and SSR (Nuxt useFetch) as alternatives.

Clarifying questions expected:

  • "Should switching products keep showing the old one while the new one loads, or show a spinner?"
  • "Is this page server-rendered?"

Code / implementation expected: Yes β€” a component (or composable) with loading/error state and an aborted stale request.

  • vue
  • data-fetching
  • composables
  • async

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

10 min read Β· 9 sections

Read the code

VueLoading, error, retry and cancellation with a fake API
<script setup>
import { ref, watch, onWatcherCleanup } from "vue";

// A FAKE network call so the demo runs anywhere: it answers after 300 ms,
// fails the first request for user 3 (a flaky server), and stops early
// when its AbortSignal fires, the way fetch does.
const names = { 1: "Ada Lovelace", 2: "Linus Torvalds", 3: "Alan Turing", 4: "Grace Hopper" };
const calls = {};
function fakeFetchUser(id, { signal }) {
  return new Promise((resolve, reject) => {
    const timer = setTimeout(() => {
      calls[id] = (calls[id] ?? 0) + 1;
      if (id === 3 && calls[id] === 1) reject(new Error("HTTP 503: try again"));
      else resolve({ id, name: names[id] ?? "Unknown" });
    }, 300);
    signal.addEventListener("abort", () => {
      clearTimeout(timer);
      reject(new DOMException("The request was aborted", "AbortError"));
    });
  });
}

const userId = ref(1);
const user = ref(null);
const loading = ref(false);
const error = ref(null);
const log = ref([]);
const attempt = ref(0); // bump to re-run the same request

async function load(id) {
  const controller = new AbortController();
  // Runs when userId changes again (or the component unmounts) before we finish.
  onWatcherCleanup(() => controller.abort());
  loading.value = true;
  error.value = null;
  try {
    user.value = await fakeFetchUser(id, { signal: controller.signal });
    log.value.push(`loaded ${id}`);
  } catch (e) {
    if (e.name === "AbortError") {
      log.value.push(`aborted ${id}`);
      return; // a newer request owns the loading flag now
    }
    user.value = null;
    error.value = e.message;
    log.value.push(`failed ${id}`);
  }
  loading.value = false;
}

watch([userId, attempt], ([id]) => load(id), { immediate: true });
const retry = () => attempt.value++;
</script>

<template>
  <button @click="userId--" :disabled="userId <= 1">Prev</button>
  <span> user #{{ userId }} </span>
  <button @click="userId++">Next</button>

  <p v-if="loading">Loading...</p>
  <p v-else-if="error" role="alert">
    {{ error }} <button @click="retry">Retry</button>
  </p>
  <p v-else-if="user">{{ user.name }}</p>

  <small>log: {{ log.join(", ") }}</small>
</template>
A reusable useFetch composable (tested against a local HTTP server)
import { ref, shallowRef, watch, toValue, onWatcherCleanup } from "vue";

export function useFetch(url) {
  const data = shallowRef(null);
  const error = shallowRef(null);
  const loading = ref(false);

  watch(
    () => toValue(url),
    async (u) => {
      const controller = new AbortController();
      onWatcherCleanup(() => controller.abort()); // new URL or scope stopped
      loading.value = true;
      error.value = null;
      try {
        const res = await fetch(u, { signal: controller.signal });
        if (!res.ok) throw new Error(`HTTP ${res.status}`); // fetch does not reject on 404
        data.value = await res.json();
      } catch (e) {
        if (e.name === "AbortError") return;
        data.value = null;
        error.value = e;
      }
      loading.value = false;
    },
    { immediate: true },
  );

  return { data, error, loading };
}

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 50 of 92 in Vue.js

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

All Vue.js questions
Up nextMedium

How do you add global properties or config?

Continue