Vue – Fetching Data from an API

December 11, 20256 min readUpdated 8/24/2026

Vue has no built-in HTTP client, and does not want one. You use fetch, or axios, or whatever you like. What matters is the shape you put around it.

The rule this lesson argues for: components should not contain fetch. One module owns the transport, and everything else calls named functions.

One module, one import

import { mockApi } from "./mock";
import { httpApi } from "./http";

export const usingMock = import.meta.env.VITE_USE_MOCK !== "false";
export const api = usingMock ? mockApi : httpApi;

Every component imports { api } from here and nothing else. No component imports http.js or mock.js directly, and that discipline is the entire reason VITE_USE_MOCK can be a one-line switch instead of a hunt through thirty files.

The payoff is that the UI can be built and reviewed with no backend running at all. The mock defines the contract; the real client implements it. In this project the frontend was built first and http.js exists to satisfy an interface the UI already assumed — which is a good way round, because it means the API was designed against a real consumer.

The transport

One request function that everything goes through:

async function request(path, { method = "GET", body, isForm = false } = {}) {
  const token = readToken();
  const headers = {};
  // Setting Content-Type on a FormData body is actively harmful: fetch has to
  // generate the multipart boundary itself, and a hand-set header omits it.
  if (!isForm) headers["Content-Type"] = "application/json";
  if (token) headers.Authorization = `Bearer ${token}`;

  let res;
  try {
    res = await fetch(`${BASE}${path}`, {
      method,
      headers,
      body: isForm ? body : body ? JSON.stringify(body) : undefined,

Then the surface is a list of one-liners:

export const httpApi = {
  /* ---- public ---- */
  feed: (params) => request(`/api/feed${qs(params)}`),
  reelBySlug: (slug) => request(`/api/reels/${encodeURIComponent(slug)}`),
  search: (params) => request(`/api/reels${qs(params)}`),
  trendingTags: () => request("/api/tags/trending"),
  commentsForReel: (reelId) => request(`/api/reels/${reelId}/comments`),
  addComment: (reelId, body) =>
    request(`/api/reels/${reelId}/comments`, { method: "POST", body: { body } }),

Every call site reads as what it does. A component says api.reelBySlug(slug) and knows nothing about verbs, headers, base URLs or JSON.

The details worth stealing

fetch does not reject on a 404

This is the fetch gotcha that catches everyone. It only rejects on a genuine network failure — DNS, connection refused, CORS. A 404 or a 500 is a perfectly successful fetch with an ok of false.

  } catch (networkError) {
    // fetch only rejects on a genuine network failure, never on a 4xx/5xx.
    // Distinguishing the two matters: "the API is down" needs a different
    // message from "you typed the wrong password".
    throw new ApiError(0, "Cannot reach the API. Is the backend running on 8087?");
  }

Distinguishing the two matters to the person using the app: "the API is down" and "you typed the wrong password" need different messages, and collapsing them into "something went wrong" makes both undebuggable.

A typed error

export class ApiError extends Error {
  constructor(status, message, fieldErrors = []) {
    super(message);
    this.status = status;
    this.fieldErrors = fieldErrors;
  }
}

Carrying the status means a caller can branch on it. Carrying fieldErrors is what makes lesson 21's server-side validation display possible.

The 401, handled in exactly one place

  if (res.status === 401 && token) {
    // notify: the auth store listens and clears its refs, which bounces the
    // router guard back to the login screen.
    clearSession({ notify: true });
    throw new ApiError(401, "Your session expired. Please sign in again.");
  }

Note the condition is not just status === 401. The comment above it in the source spells out why, and it is a real bug the application had: a 401 means two different things depending on whether a token was sent. With a token, the session expired. Without one, this is the sign-in attempt and the credentials were wrong — and treating that as an expiry replaces the server's "Invalid email or password" with a nonsensical "Your session expired" on the login form.

Handling it centrally is the point. Thirty components do not each need to know what a 401 means.

FormData and Content-Type

// Setting Content-Type on a FormData body is actively harmful: fetch has to
// generate the multipart boundary itself, and a hand-set header omits it.
if (!isForm) headers["Content-Type"] = "application/json";

A multipart upload with a hand-set Content-Type fails on the server with a parsing error that does not mention boundaries at all. Let fetch set it.

Loading and error state in a component

The transport is generic; the state is not. Every view that loads something needs the same three things:

async function load() {
  loading.value = true;
  try {
    results.value = await api.search({
      q: route.query.q ?? "",
      tag: route.query.tag ?? null,
      page: Number(route.query.page ?? 1),
      size: 12,
    });

loading set before, cleared in finally. Errors surfaced to the user rather than swallowed. The result assigned in one place.

The finally is the part people leave out, and the failure is nasty: an error leaves the spinner turning forever, which reads as "the app hung" rather than "something went wrong".

And the template renders all three states explicitly:

<LoadingSpinner v-if="loading" label="Searching…" />
<EmptyState v-else-if="!results.content.length" title="Nothing matched" />
<template v-else>
  <!-- results -->
</template>

Loading, empty, and loaded. A component that only handles the third renders a broken-looking page for the first two, and "empty" and "still loading" look identical to a user if you do not separate them.

Where to put the call

Views fetch. Components receive props.

A leaf component that fetches its own data is impossible to reason about — you cannot tell how many requests a page makes without reading every component on it, and rendering a list of twenty cards fires twenty requests. Keep the calls in routed views and pass the results down.

The exception is a component that genuinely owns a resource, like the comment panel on a reel page. That is a judgement call, and the test is whether the component makes sense used twice on one page.

Streaming

Not everything is request/response. The admin dashboard's live counters come over server-sent events, and the API module hides that too:

  subscribeToStats(onEvent) {
    const url = `${BASE}/api/admin/stream/stats${qs({ token: readToken() })}`;
    const es = new EventSource(url);
    es.addEventListener("stats", (e) => {
      try {
        onEvent(JSON.parse(e.data));
      } catch {
        /* a malformed frame should not kill the stream */
      }
    });
    // EventSource reconnects on its own; log once so a persistent failure is
    // visible in the console rather than silently doing nothing.

It returns an unsubscribe function, which the dashboard calls in onBeforeUnmount — the lesson 14 rule, applied to a connection instead of a listener.

EventSource cannot send an Authorization header, which is a limitation of the browser API rather than an oversight, so the token rides as a query parameter and the endpoint accepts it there.

What about a data-fetching library?

Everything above is hand-rolled, and for an application this size that is the right call: it is maybe 150 lines and has no dependencies.

Once you want caching across components, background refetching, deduplicating simultaneous requests for the same resource, or optimistic updates with rollback, stop hand-rolling and use TanStack Query's Vue adapter or VueUse's useFetch. The signal you have reached that point is usually your third component needing the same data and each fetching it separately.

Next: Transitions and Teleport.