Skip to content

React API Integration — Fetching Data with React Query & SWR

DodaTech 10 min read

In this tutorial, you'll learn about React API Integration. We cover key concepts, practical examples, and best practices.

What You'll Learn

Integrate external APIs in React using TanStack React Query and SWR — data fetching, caching, background refetching, optimistic mutations, and infinite scrolling with proper loading and error states.

Why It Matters

Every real-world React app talks to a server. Without a structured approach to data fetching, you end up with scattered useEffect calls, inconsistent loading states, no caching, and race conditions. Libraries like React Query and SWR solve these problems out of the box.

Real-World Use

A dashboard that polls for live metrics every 30 seconds, a search bar that debounces queries and caches recent results, or a social feed with infinite scroll — all need reliable API integration with smooth UX.

The Problem: Fetching in useEffect

Before data-fetching libraries, every React developer wrote some version of this:

import { useState, useEffect } from "react";

function UserList() {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    let cancelled = false;

    async function fetchUsers() {
      try {
        setLoading(true);
        setError(null);
        const response = await fetch("https://api.example.com/users");
        if (!response.ok) throw new Error(`HTTP ${response.status}`);
        const data = await response.json();
        if (!cancelled) setUsers(data);
      } catch (err) {
        if (!cancelled) setError(err.message);
      } finally {
        if (!cancelled) setLoading(false);
      }
    }

    fetchUsers();
    return () => { cancelled = true; };
  }, []);

  if (loading) return <p>Loading users...</p>;
  if (error) return <p style={{ color: "red" }}>Error: {error}</p>;
  return (
    <ul>
      {users.map(user => <li key={user.id}>{user.name}</li>)}
    </ul>
  );
}

Problems with this approach:

  • No caching — remounting the component re-fetches
  • No background refetching — stale data stays stale
  • No deduplication — two components fetching the same URL fire two requests
  • Manual loading/error state management — repetitive and error-prone
  • Race conditions — a slow response can overwrite a newer one

TanStack React Query (React Query v5)

React Query (now TanStack Query) is the most popular data-fetching library for React. It manages server state with automatic caching, background refetching, and powerful devtools.

Setup

npm install @tanstack/react-query
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

const queryClient = new QueryClient();

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <UserList />
    </QueryClientProvider>
  );
}

useQuery — Fetching Data

import { useQuery } from "@tanstack/react-query";

async function fetchUsers() {
  const response = await fetch("https://api.example.com/users");
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
  return response.json();
}

function UserList() {
  const {
    data: users,
    isLoading,
    isError,
    error,
    refetch,
  } = useQuery({
    queryKey: ["users"],
    queryFn: fetchUsers,
    staleTime: 5 * 60 * 1000,     // 5 minutes before data is stale
    cacheTime: 30 * 60 * 1000,     // 30 minutes in cache
    retry: 2,                       // Retry twice on failure
  });

  if (isLoading) return <p>Loading users...</p>;
  if (isError) return (
    <div style={{ color: "red" }}>
      <p>Error: {error.message}</p>
      <button onClick={refetch} style={{ cursor: "pointer" }}>Retry</button>
    </div>
  );

  return (
    <ul>
      {users.map(user => <li key={user.id}>{user.name}</li>)}
    </ul>
  );
}

Expected output: On first render, "Loading users..." appears. Once the API responds, the user list renders. If the API fails, the error message and a "Retry" button appear. Subsequent visits use the cached data.

useMutation — Writing Data

import { useMutation, useQueryClient } from "@tanstack/react-query";

function AddUserForm() {
  const queryClient = useQueryClient();
  const [name, setName] = useState("");

  const mutation = useMutation({
    mutationFn: (newUser) =>
      fetch("https://api.example.com/users", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(newUser),
      }).then(res => {
        if (!res.ok) throw new Error("Failed to create user");
        return res.json();
      }),
    onSuccess: () => {
      // Invalidate and refetch the users list
      queryClient.invalidateQueries({ queryKey: ["users"] });
      setName("");
    },
    onError: (error) => {
      console.error("Mutation failed:", error);
    },
  });

  function handleSubmit(e) {
    e.preventDefault();
    mutation.mutate({ name });
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        value={name}
        onChange={(e) => setName(e.target.value)}
        placeholder="Enter name"
      />
      <button type="submit" disabled={mutation.isPending}>
        {mutation.isPending ? "Adding..." : "Add User"}
      </button>
      {mutation.isError && (
        <p style={{ color: "red" }}>
          Error: {mutation.error.message}
        </p>
      )}
    </form>
  );
}

Expected output: Submitting the form calls the API. The button shows "Adding..." during the request. On success, the input clears and the user list automatically refreshes. On failure, an error message appears.

SWR (Stale-While-Revalidate)

SWR by Vercel follows the same stale-while-revalidate caching strategy. Data is served from cache immediately (stale), then re-fetched in the background (revalidate).

npm install swr
import useSWR from "swr";

const fetcher = (url) => fetch(url).then(res => {
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json();
});

function UserProfile({ userId }) {
  const { data: user, error, isLoading, mutate } = useSWR(
    userId ? `https://api.example.com/users/${userId}` : null,
    fetcher,
    {
      revalidateOnFocus: true,
      dedupingInterval: 2000,
      errorRetryCount: 3,
    }
  );

  if (isLoading) return <p>Loading...</p>;
  if (error) return <p style={{ color: "red" }}>Failed to load user</p>;
  if (!user) return <p>Select a user</p>;

  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
      <button onClick={() => mutate()} style={{ cursor: "pointer" }}>
        Refresh
      </button>
    </div>
  );
}

Expected output: The user data appears immediately from cache if available, then updates in the background. The "Refresh" button triggers a manual re-fetch via mutate().

Caching Strategies

Strategy React Query SWR Behavior
Stale-while-revalidate staleTime default 0 Default Show cached data, refetch in background
Cache-first staleTime: Infinity revalidateIfStale: false Never refetch unless told
Network-only staleTime: 0, cacheTime: 0 revalidateOnMount: true Always fetch fresh
Polling refetchInterval: 30000 refreshInterval: 30000 Refetch every N ms
Focus refetch refetchOnWindowFocus: true revalidateOnFocus: true Refetch on tab focus

Optimistic Updates

Optimistic updates show the expected result immediately before the server confirms, then roll back on failure.

import { useMutation, useQueryClient } from "@tanstack/react-query";

function useToggleTodo() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: ({ id, completed }) =>
      fetch(`https://api.example.com/todos/${id}`, {
        method: "PATCH",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ completed: !completed }),
      }).then(res => {
        if (!res.ok) throw new Error("Update failed");
        return res.json();
      }),

    onMutate: async ({ id, completed }) => {
      // Cancel outgoing queries
      await queryClient.cancelQueries({ queryKey: ["todos"] });

      // Snapshot previous value
      const previousTodos = queryClient.getQueryData(["todos"]);

      // Optimistically update
      queryClient.setQueryData(["todos"], (old) =>
        old.map(todo =>
          todo.id === id ? { ...todo, completed: !completed } : todo
        )
      );

      return { previousTodos };
    },

    onError: (err, vars, context) => {
      // Rollback on error
      queryClient.setQueryData(["todos"], context.previousTodos);
    },

    onSettled: () => {
      // Refetch to ensure server sync
      queryClient.invalidateQueries({ queryKey: ["todos"] });
    },
  });
}

Expected output: Toggling a todo instantly updates the UI. If the server request fails, the UI reverts to the previous state automatically.

Infinite Queries / Pagination

import { useInfiniteQuery } from "@tanstack/react-query";

async function fetchPosts({ pageParam = 1 }) {
  const response = await fetch(
    `https://api.example.com/posts?page=${pageParam}&limit=10`
  );
  return response.json();
}

function PostList() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    isLoading,
    isError,
  } = useInfiniteQuery({
    queryKey: ["posts"],
    queryFn: fetchPosts,
    getNextPageParam: (lastPage) => lastPage.nextPage ?? undefined,
    initialPageParam: 1,
  });

  if (isLoading) return <p>Loading posts...</p>;
  if (isError) return <p style={{ color: "red" }}>Failed to load posts</p>;

  return (
    <div>
      {data.pages.map((page, i) => (
        <div key={i}>
          {page.posts.map(post => (
            <div key={post.id} style={{ padding: "12px", borderBottom: "1px solid #eee" }}>
              <h3>{post.title}</h3>
              <p>{post.body}</p>
            </div>
          ))}
        </div>
      ))}
      <div style={{ textAlign: "center", padding: "16px" }}>
        {isFetchingNextPage && <p>Loading more...</p>}
        {hasNextPage && (
          <button
            onClick={() => fetchNextPage()}
            disabled={isFetchingNextPage}
            style={{
              padding: "8px 16px",
              background: "#007bff",
              color: "#fff",
              border: "none",
              borderRadius: "4px",
              cursor: "pointer",
            }}
          >
            Load More
          </button>
        )}
      </div>
    </div>
  );
}

Expected output: The first page of posts renders. A "Load More" button fetches the next page and appends it. When no more pages exist, the button disappears.

Data Flow Architecture

flowchart TD
    A[Component] -->|useQuery / useSWR| B[Cache]
    B -->|Cache hit & fresh| C[Render data]
    B -->|Cache hit & stale| D[Show cached data]
    D --> E[Background refetch]
    E --> F{Success?}
    F -->|Yes| G[Update cache]
    G --> C
    F -->|No| H[Keep stale data]
    H --> I[Show error toast]
    B -->|No cache| J[Fetch from API]
    J --> F

Learning Path

flowchart LR
    A[React Basics] --> B[Hooks Guide]
    B --> C[Context API]
    C --> D[Custom Hooks]
    D --> E[Design Patterns]
    E --> F[Error Boundaries]
    F --> G[API Integration]
    G --> H[Final Project]
    G -->|You are here| G

Common Errors

1. Missing Query Key Consistency

If two components fetch the same data but use different query keys, they create separate caches. Always use the same key structure for the same resource.

2. Forgetting to Handle isPending vs isLoading

React Query v5 uses isPending for the initial loading state and isLoading for any loading state including background refetches. Check both or use isFetching for refetch states.

3. Mutations Without Invalidation

Calling mutate without invalidateQueries afterward means the UI shows stale data. Always invalidate the relevant query key on mutation success.

4. Not Providing an Error Boundary

If useQuery throws because of a network failure and you do not handle isError, the error propagates up. Wrap API-dependent components in error boundaries.

5. Over-fetching with Short Stale Times

Setting staleTime: 0 on every query causes constant background refetches. Match the stale time to how frequently the data actually changes on the server.

6. Race Conditions with Multiple Mutations

If two mutations target the same resource simultaneously, the onMutate callback can overwrite each other's optimistic updates. Use the returned context object to roll back the correct snapshot.

7. Memory Leaks from Unbounded Infinite Queries

Without a maximum page limit, infinite queries grow memory indefinitely. Cap the number of stored pages with maxPages or prune old pages manually.

Security Considerations

When integrating APIs in React, always validate and sanitize data from external sources. A REST API response could contain malicious content. Never use dangerouslySetInnerHTML with API data. Use a library like DOMPurify if you must render HTML from the server. This is the same principle used in Doda Browser to sanitize untrusted web content.

Practice Questions

What is the difference between staleTime and cacheTime in React Query?

staleTime is how long data stays fresh before refetch is needed. cacheTime is how long unused data stays in memory before garbage collection. Data can be fresh (no refetch) or cached (available offline).

How does SWR handle concurrent requests for the same data?

SWR deduplicates requests within a dedupingInterval (default 2 seconds). If two components request the same URL, only one fetch fires and both receive the result.

When should I use useMutation instead of useQuery?

Use useMutation for create, update, and delete operations that change server state. Use useQuery for read operations. Mutations have different lifecycle states (isPending, isError, isSuccess) and do not cache results.

Why does my component re-fetch when I switch browser tabs?

Both React Query and SWR default to refetchOnWindowFocus: true / revalidateOnFocus: true. This keeps data fresh when the user returns to the tab. Disable it by setting the option to false if unwanted.

Can I use React Query with GraphQL?

Yes. React Query works with any data-fetching function. For GraphQL, pass your GraphQL client's request function as queryFn and use the query string as part of the queryKey

Challenge

Build a useInfiniteSWR implementation using SWR's useSWRInfinite hook for a paginated product listing. Include a "Load More" button, optimistic prefetching of the next page, and a loading skeleton for each page load.

Real-World Task

Take an existing React component that uses raw useEffect for data fetching and refactor it to use TanStack React Query. Measure the reduction in lines of code, the elimination of loading/error boilerplate, and verify that cached data persists across component remounts.


This tutorial is built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro — applications that fetch real-time security data from APIs and display it with minimal latency and maximum reliability.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro