Type something to search...
Fetching Data in React: fetch, Axios, and Beyond

Fetching Data in React: fetch, Axios, and Beyond

React doesn't come with a data-fetching layer. It renders UI from state, and how that state gets filled from an API is up to you. That freedom is why there are so many approaches: fetch inside useEffect, Axios with interceptors, TanStack Query, SWR, the use hook with Suspense, and router loaders. Each one solves a slightly different problem, and picking the wrong one usually shows up later as race conditions, duplicate requests, or stale data.

The HTTP client and the data-fetching strategy are two separate decisions. fetch and Axios answer "how do I send a request and read the response?". Libraries like TanStack Query answer "when should I fetch, how long is the data valid, and how do components share it?". Most production apps need an answer to both.

This guide starts with plain fetch in an effect and fixes its common bugs, builds a small typed API client, compares it with Axios, and then moves up to TanStack Query, Suspense with use, and React Router loaders, so you can choose the right level for your app.

Fetching in useEffect

The most basic approach is to call fetch in an effect and store the result in state:

import { useEffect, useState } from "react";

type Post = { id: number; title: string; body: string };

export function PostView({ id }: { id: number }) {
  const [post, setPost] = useState<Post | null>(null);

  useEffect(() => {
    fetch(`https://jsonplaceholder.typicode.com/posts/${id}`)
      .then((res) => res.json())
      .then(setPost);
  }, [id]);

  if (!post) return <p>Loading…</p>;
  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.body}</p>
    </article>
  );
}

This works in a demo, but it has four real bugs:

  1. No error handling. fetch only rejects on network failures. A 404 or 500 resolves normally, and res.json() may fail or return an error body you treat as a post.
  2. Race conditions. If id changes from 1 to 2 quickly, both requests are in flight. If request 1 finishes last, the component shows post 1 while the URL says 2.
  3. No cancellation. Requests keep running after the component unmounts.
  4. No error or loading distinction. null means both "loading" and "failed".

Fixing the Effect

Here's the same component with those problems handled:

import { useEffect, useState } from "react";

type Post = { id: number; title: string; body: string };

type State =
  | { status: "loading" }
  | { status: "error"; error: Error }
  | { status: "success"; data: Post };

export function PostView({ id }: { id: number }) {
  const [state, setState] = useState<State>({ status: "loading" });

  useEffect(() => {
    const controller = new AbortController();
    setState({ status: "loading" });

    async function load() {
      try {
        const res = await fetch(`https://jsonplaceholder.typicode.com/posts/${id}`, {
          signal: controller.signal,
        });
        if (!res.ok) throw new Error(`Request failed with status ${res.status}`);
        const data: Post = await res.json();
        setState({ status: "success", data });
      } catch (error) {
        if (controller.signal.aborted) return;
        setState({ status: "error", error: error as Error });
      }
    }

    load();
    return () => controller.abort();
  }, [id]);

  if (state.status === "loading") return <p>Loading…</p>;
  if (state.status === "error") return <p role="alert">{state.error.message}</p>;

  return (
    <article>
      <h1>{state.data.title}</h1>
      <p>{state.data.body}</p>
    </article>
  );
}

The AbortController solves both the race and the unmount problem. When id changes, React runs the cleanup for the previous effect, which aborts the old request before starting the new one. Aborted requests reject with an AbortError, which we ignore. The discriminated union makes it impossible to read data while loading.

In development with Strict Mode, you'll see this effect run twice on mount, and the first request gets aborted. That's expected, and the abort cleanup is exactly what makes it harmless. Why your useEffect runs twice in Strict Mode explains the reasoning.

Building a Small Typed fetch Client

Repeating the res.ok check, JSON parsing, base URL, and headers in every component gets old fast. A thin wrapper around fetch centralizes them:

// src/lib/api.ts
const BASE_URL = import.meta.env.VITE_API_URL ?? "https://api.example.com";

export class ApiError extends Error {
  constructor(
    public status: number,
    public body: unknown,
  ) {
    super(`API request failed with status ${status}`);
    this.name = "ApiError";
  }
}

type RequestOptions = Omit<RequestInit, "body" | "headers"> & {
  body?: unknown;
  headers?: Record<string, string>;
};

export async function api<T>(path: string, options: RequestOptions = {}): Promise<T> {
  const { body, headers, ...rest } = options;
  const token = localStorage.getItem("token");

  const res = await fetch(`${BASE_URL}${path}`, {
    ...rest,
    headers: {
      Accept: "application/json",
      ...(body !== undefined ? { "Content-Type": "application/json" } : {}),
      ...(token ? { Authorization: `Bearer ${token}` } : {}),
      ...headers,
    },
    body: body !== undefined ? JSON.stringify(body) : undefined,
  });

  if (!res.ok) {
    const errorBody = await res.json().catch(() => null);
    throw new ApiError(res.status, errorBody);
  }

  if (res.status === 204) return undefined as T;
  return res.json() as Promise<T>;
}

Usage stays short and typed:

type Todo = { id: number; title: string; completed: boolean };

const todos = await api<Todo[]>("/todos");
const created = await api<Todo>("/todos", { method: "POST", body: { title: "Write docs" } });
await api<void>(`/todos/${created.id}`, { method: "DELETE" });

An ApiError class lets callers distinguish HTTP errors from network errors and check error.status, such as redirecting to login on 401.

Validating Responses at Runtime

The type parameter T is a promise to TypeScript, not a guarantee. If the API returns a different shape, your app crashes somewhere far from the fetch. For important endpoints, validate with a schema library like Zod:

import { z } from "zod";
import { api } from "./api";

const TodoSchema = z.object({
  id: z.number(),
  title: z.string(),
  completed: z.boolean(),
});

export type Todo = z.infer<typeof TodoSchema>;

export async function getTodos(): Promise<Todo[]> {
  const data = await api<unknown>("/todos");
  return z.array(TodoSchema).parse(data);
}

Now a contract change produces a clear validation error at the boundary instead of an undefined is not a function deep in a component.

Using Axios

Axios is a popular HTTP client with a different API on top of the same network layer. Its main conveniences:

  • It rejects on non-2xx status codes by default, so you don't need the res.ok check.
  • It parses JSON automatically and serializes request bodies.
  • It has instances with shared config and interceptors for requests and responses.
  • It supports upload and download progress events.
npm install axios

Create an instance with shared settings:

// src/lib/http.ts
import axios from "axios";

export const http = axios.create({
  baseURL: import.meta.env.VITE_API_URL ?? "https://api.example.com",
  timeout: 10_000,
  headers: { Accept: "application/json" },
});

http.interceptors.request.use((config) => {
  const token = localStorage.getItem("token");
  if (token) config.headers.Authorization = `Bearer ${token}`;
  return config;
});

http.interceptors.response.use(
  (response) => response,
  (error) => {
    if (axios.isAxiosError(error) && error.response?.status === 401) {
      window.location.assign("/login");
    }
    return Promise.reject(error);
  },
);

Requests are typed through a generic, and the data is on response.data:

import { http } from "./http";

type Todo = { id: number; title: string; completed: boolean };

const { data: todos } = await http.get<Todo[]>("/todos");
const { data: created } = await http.post<Todo>("/todos", { title: "Write docs" });

// Cancellation uses the same AbortController as fetch
const controller = new AbortController();
http.get<Todo[]>("/todos", { signal: controller.signal });
controller.abort();

Interceptors are especially handy for refreshing expired access tokens and retrying the original request. The post on authentication with JWT and refresh tokens builds that flow.

fetch or Axios?

Both are fine choices. Some practical differences:

  • Bundle size. fetch is built in. Axios adds roughly 13 to 15 KB gzipped.
  • Error semantics. Axios rejects on HTTP errors, fetch doesn't. Either way, wrap it once and handle errors consistently.
  • Interceptors. Axios has them built in. With fetch, your wrapper function plays that role.
  • Progress events. Axios exposes upload progress. fetch can stream downloads but has no simple upload progress API.
  • Streaming. fetch gives you res.body as a ReadableStream, which matters for streaming AI responses or large files.

If you're starting fresh, a small fetch wrapper like the one above covers most needs. If your team already uses Axios and relies on interceptors, there's no urgent reason to switch.

Beyond Effects: TanStack Query

Even a perfect useEffect fetch leaves big gaps. Two components that need the same data make two requests. Navigating away and back refetches everything. Nothing refreshes stale data when the user returns to the tab. Mutations don't update lists that display the changed item. You end up building a cache by hand.

TanStack Query is that cache, and it works with any HTTP client. You give it a key and a function that returns a promise:

npm install @tanstack/react-query
// src/main.tsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import App from "./App";

const queryClient = new QueryClient({
  defaultOptions: { queries: { staleTime: 60_000 } },
});

createRoot(document.getElementById("root")!).render(
  <StrictMode>
    <QueryClientProvider client={queryClient}>
      <App />
    </QueryClientProvider>
  </StrictMode>,
);
// src/features/todos/TodoList.tsx
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "../../lib/api";

type Todo = { id: number; title: string; completed: boolean };

export function TodoList() {
  const queryClient = useQueryClient();

  const { data, isPending, isError, error } = useQuery({
    queryKey: ["todos"],
    queryFn: ({ signal }) => api<Todo[]>("/todos", { signal }),
  });

  const addTodo = useMutation({
    mutationFn: (title: string) => api<Todo>("/todos", { method: "POST", body: { title } }),
    onSuccess: () => queryClient.invalidateQueries({ queryKey: ["todos"] }),
  });

  if (isPending) return <p>Loading…</p>;
  if (isError) return <p role="alert">{error.message}</p>;

  return (
    <>
      <ul>
        {data.map((todo) => (
          <li key={todo.id}>{todo.title}</li>
        ))}
      </ul>
      <button
        type="button"
        disabled={addTodo.isPending}
        onClick={() => addTodo.mutate("New todo")}
      >
        Add todo
      </button>
    </>
  );
}

Compared with the effect version, you get deduplicated requests, shared cache across components, automatic cancellation through signal, background refetching when data is stale, retries, and simple cache invalidation after mutations. There's no hand-written loading state or abort logic. Managing server state with TanStack Query goes deep on keys, stale time, and optimistic updates.

SWR and RTK Query solve the same problem with different APIs. SWR is smaller with fewer features. RTK Query fits naturally if you already use Redux Toolkit.

Suspense and the use Hook

React 19's use hook reads the value of a promise during render. If the promise is pending, the component suspends and the nearest Suspense boundary shows its fallback. Errors go to the nearest error boundary:

import { Suspense, use } from "react";
import { ErrorBoundary } from "react-error-boundary";

type User = { id: number; name: string };

function UserName({ userPromise }: { userPromise: Promise<User> }) {
  const user = use(userPromise);
  return <h2>{user.name}</h2>;
}

export function Profile({ userPromise }: { userPromise: Promise<User> }) {
  return (
    <ErrorBoundary fallback={<p role="alert">Could not load user.</p>}>
      <Suspense fallback={<p>Loading user…</p>}>
        <UserName userPromise={userPromise} />
      </Suspense>
    </ErrorBoundary>
  );
}

ErrorBoundary here comes from the small react-error-boundary package. The catch: the promise must be stable across renders. Creating it inside the component that calls use, as in use(fetch(...)), makes a new promise every render and loops forever. The promise should come from a cache, a router loader, a Server Component, or a parent that creates it once. In client-only apps, TanStack Query's useSuspenseQuery gives you the Suspense model with caching built in.

Router Loaders

Fetching in components has another structural problem: waterfalls. A parent fetches, renders, then a child starts its own fetch. React Router v7 loaders start fetching as soon as navigation begins, in parallel for all matched routes, before any component renders:

// src/routes.tsx
import { createBrowserRouter, useLoaderData, type LoaderFunctionArgs } from "react-router";
import { api } from "./lib/api";

type Post = { id: number; title: string; body: string };

async function postLoader({ params, request }: LoaderFunctionArgs) {
  return api<Post>(`/posts/${params.id}`, { signal: request.signal });
}

function PostPage() {
  const post = useLoaderData<typeof postLoader>();
  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.body}</p>
    </article>
  );
}

export const router = createBrowserRouter([
  { path: "/posts/:id", loader: postLoader, Component: PostPage },
]);

The router passes a request.signal that aborts when the user navigates away, so cancellation is handled for you. Loaders combine well with TanStack Query too: the loader calls queryClient.ensureQueryData, and the component reads the same data with useQuery, getting both early fetching and a shared cache.

Choosing an Approach

A rough guide:

  • Small widget or one-off request: fetch in an effect with AbortController is fine.
  • Any app with shared or frequently revisited server data: TanStack Query (or SWR, or RTK Query) on top of a small API client.
  • Route-driven pages: router loaders to start fetching early, optionally feeding a query cache.
  • Framework with Server Components: fetch on the server and pass data or promises down, using use on the client where needed.

Common Mistakes When Fetching Data

  • Not checking res.ok. fetch resolves on 404 and 500. Treat non-2xx responses as errors.
  • Ignoring race conditions. Abort the previous request in the effect cleanup, or use a library that does.
  • Making the effect callback async. useEffect(async () => ...) returns a promise instead of a cleanup function. Define an async function inside and call it.
  • Creating promises during render for use. The promise must be cached or created outside the component.
  • Storing server data in global client state. Copying API responses into Redux or Context by hand recreates a cache badly. Use a server-state library.
  • Trusting response types. A generic type doesn't validate anything. Validate critical responses with a schema.
  • Fetching in a chain of nested components. Lift fetches into loaders or start them in parallel to avoid waterfalls.

Frequently Asked Questions (FAQ) About Fetching Data in React

Not wrong, but it's low level. It works for simple cases if you handle errors, loading, and cancellation correctly. For anything shared between components, cached, or refetched, a library like TanStack Query handles those concerns more reliably than hand-written effects.

Usually not. Modern browsers and Node have a full fetch implementation, and a small wrapper covers base URLs, headers, and error handling. Axios remains useful if you want built-in interceptors, upload progress events, or your codebase already depends on it.

Yes. TanStack Query doesn't care how data is fetched, only that the query function returns a promise. Return response.data from an Axios call in queryFn, and pass the provided signal to Axios so queries can cancel requests.

useEffect runs after render and stores results in state, so you handle loading and errors manually. use reads a promise during render and suspends until it resolves, letting Suspense and error boundaries handle loading and errors. use needs a stable, cached promise to work correctly.

Start requests as early as possible and in parallel. Router loaders fetch for all matched routes at once, Promise.all combines independent requests, and query libraries can prefetch data before a component renders. Avoid patterns where a child only starts fetching after its parent finishes.

In an environment variable, like VITE_API_URL in a Vite app, read once in your API client module. That way development, staging, and production can point at different servers without code changes. Remember that client-side environment variables are visible to users, so never put secrets in them.

Conclusion

Fetching data in React involves two layers. The HTTP client, whether fetch or Axios, sends requests and parses responses, and it's worth wrapping once so base URLs, headers, and errors are handled consistently. The data-fetching strategy decides when to fetch and how to cache. useEffect with an AbortController is enough for simple cases, TanStack Query handles caching and sharing for real apps, and Suspense with use and router loaders move fetching earlier and out of components.

If your app currently fetches in effects, start by building a typed API client and fixing error handling and cancellation. Then move one shared resource, like the current user or a frequently used list, into TanStack Query and see how much code disappears. From there, add loaders on routes that suffer from waterfalls.

Tags :
Share :

Related Posts

A Practical Guide to useEffect and Its Dependency Array

A Practical Guide to useEffect and Its Dependency Array

useEffect is the hook people get wrong most often, and the dependency array is usually where it goes wrong. Leave a value out and your effect works

Continue Reading
Accessibility Best Practices for React Developers

Accessibility Best Practices for React Developers

React makes it easy to build interfaces out of anything. A div with an onClick looks and behaves like a button for a mouse user, so it ships. The

Continue Reading
Animations in React with Motion (Framer Motion)

Animations in React with Motion (Framer Motion)

CSS transitions get you far, until you need to animate something leaving the page. React removes the element from the DOM immediately, so there's not

Continue Reading