ReactJavaScriptMedium

Patterns for async data loading in React

01

The Short Answer

React offers several patterns for loading async data — from the basic useEffect + useState approach to custom hooks, libraries like React Query/SWR, and the newer Suspense model. Each pattern trades off simplicity against features like caching, deduplication, error handling, and loading states. The right choice depends on your app's complexity and how much infrastructure you want to manage yourself.

02

Pattern 1: useEffect + useState

The most basic pattern: trigger a fetch in useEffect, store the result in state, and track loading/error states manually. This works for simple cases but gets repetitive quickly — you end up writing the same loading/error/data boilerplate in every component that fetches data.

basic-fetch.tsxtypescript
function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    const controller = new AbortController();
    setLoading(true);
    setError(null);

    fetch(`/api/users/${userId}`, { signal: controller.signal })
      .then((res) => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json();
      })
      .then((data) => setUser(data))
      .catch((err) => {
        if (err.name !== 'AbortError') setError(err);
      })
      .finally(() => setLoading(false));

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

  if (loading) return <Spinner />;
  if (error) return <ErrorMessage error={error} />;
  if (!user) return null;

  return <div>{user.name}</div>;
}

Limitations of this pattern

  • Repetitive boilerplate (loading, error, data) in every component
  • No caching — refetches on every mount even if data hasn't changed
  • No deduplication — multiple components fetching the same data make separate requests
  • Race conditions require manual AbortController handling
  • No background refetching or stale-while-revalidate
03

Pattern 2: Custom Hook

Extract the fetch logic into a reusable hook that handles loading, error, and abort states. This eliminates boilerplate in components and centralizes the fetching pattern. The hook encapsulates the complexity while exposing a clean interface.

use-fetch.tstypescript
type UseFetchResult<T> = {
  data: T | null;
  loading: boolean;
  error: Error | null;
  refetch: () => void;
};

function useFetch<T>(url: string): UseFetchResult<T> {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);
  const [fetchCount, setFetchCount] = useState(0);

  useEffect(() => {
    const controller = new AbortController();
    setLoading(true);
    setError(null);

    fetch(url, { signal: controller.signal })
      .then((res) => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json();
      })
      .then(setData)
      .catch((err) => {
        if (err.name !== 'AbortError') setError(err);
      })
      .finally(() => setLoading(false));

    return () => controller.abort();
  }, [url, fetchCount]);

  const refetch = () => setFetchCount((count) => count + 1);

  return { data, loading, error, refetch };
}

Now components become much cleaner — they just call the hook and destructure the result. But this still doesn't solve caching or deduplication. Every component instance that calls useFetch('/api/users/1') makes its own request.

using-custom-hook.tsxtypescript
function UserProfile({ userId }: { userId: string }) {
  const { data: user, loading, error } = useFetch<User>(`/api/users/${userId}`);

  if (loading) return <Spinner />;
  if (error) return <ErrorMessage error={error} />;
  if (!user) return null;

  return <div>{user.name}</div>;
}
04

Pattern 3: Data Fetching Libraries (React Query / SWR)

Libraries like TanStack Query (React Query) and SWR solve all the problems of manual fetching: caching, deduplication, background refetching, optimistic updates, pagination, and more. They manage a global cache keyed by query keys, so multiple components requesting the same data share a single request and cache entry.

react-query-example.tsxtypescript
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';

function UserProfile({ userId }: { userId: string }) {
  const { data: user, isLoading, error } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetch(`/api/users/${userId}`).then((res) => res.json()),
    staleTime: 5 * 60 * 1000, // Consider fresh for 5 minutes
  });

  if (isLoading) return <Spinner />;
  if (error) return <ErrorMessage error={error} />;

  return <div>{user.name}</div>;
}

// Mutation with cache invalidation
function UpdateUserButton({ userId }: { userId: string }) {
  const queryClient = useQueryClient();

  const mutation = useMutation({
    mutationFn: (newName: string) =>
      fetch(`/api/users/${userId}`, {
        method: 'PATCH',
        body: JSON.stringify({ name: newName }),
      }),
    onSuccess: () => {
      // Invalidate and refetch the user query
      queryClient.invalidateQueries({ queryKey: ['user', userId] });
    },
  });

  return <button onClick={() => mutation.mutate('New Name')}>Update</button>;
}

The library handles caching, deduplication, refetching on window focus, retry logic, and garbage collection automatically. If two components use the same query key, only one request is made and both components share the cached result.

05

Pattern 4: Server Components (Next.js)

With React Server Components (in frameworks like Next.js), you can fetch data directly in the component without hooks, effects, or loading states. The component runs on the server, fetches data, and sends the rendered HTML to the client. This eliminates client-side waterfalls and simplifies the data loading model significantly.

server-component.tsxtypescript
// This runs on the server — no useState, no useEffect, no loading state
async function UserProfile({ userId }: { userId: string }) {
  const res = await fetch(`https://api.example.com/users/${userId}`, {
    next: { revalidate: 300 }, // Cache for 5 minutes
  });

  if (!res.ok) throw new Error('Failed to fetch user');
  const user = await res.json();

  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
    </div>
  );
}

// Pair with a loading.tsx for Suspense boundary
// app/users/[id]/loading.tsx
export default function Loading() {
  return <Spinner />;
}

Server Components eliminate the loading/error/data state machine entirely for initial page loads. The tradeoff is that they can't use hooks or browser APIs — they're purely for rendering with server-side data. Interactive parts still need client components.

06

Comparison

PatternCachingDeduplicationComplexityBest for
useEffect + useStateNoneNoneLowSimple one-off fetches, learning
Custom hookNone (unless you add it)NoneMediumReducing boilerplate, small apps
React Query / SWRBuilt-inBuilt-inMediumMost production apps
Server ComponentsFramework-levelFramework-levelLow (in supported frameworks)Next.js / RSC-enabled apps
07

Common Pitfalls

🏎️

Race conditions without cancellation

If a user triggers multiple fetches quickly (typing in search, navigating fast), responses can arrive out of order. The last response to arrive wins — which might not be the latest request.

Use AbortController to cancel previous requests, or use a library that handles this automatically (React Query, SWR).

🌊

Waterfall requests

Fetching data in nested components creates sequential waterfalls — child can't fetch until parent renders. Each level adds latency.

Hoist data fetching to the route level, use parallel fetching (Promise.all), or use Server Components which can fetch in parallel on the server.

💾

No caching strategy

Refetching the same data on every mount wastes bandwidth and makes the app feel slow. Users see loading spinners for data they already loaded seconds ago.

Use a caching library (React Query, SWR) or implement stale-while-revalidate: show cached data immediately, refetch in the background.

08

Why Interviewers Ask This

This question reveals how much production React experience you have. Interviewers want to see that you know the basic useEffect pattern and its limitations, understand why libraries like React Query exist (caching, deduplication, race conditions), can discuss the tradeoffs between approaches, and are aware of modern patterns like Server Components. A junior might only know useEffect; a senior can discuss when each pattern is appropriate and why.

Quick Revision Cheat Sheet

Basic: useEffect + useState + AbortController — works but repetitive

Custom hook: Extracts boilerplate, still no caching or deduplication

React Query/SWR: Caching, deduplication, background refetch, retry — production standard

Server Components: Fetch on server, no client loading state, eliminates waterfalls

Race conditions: Always cancel stale requests (AbortController or library)

Waterfalls: Fetch at route level or use Promise.all for parallel requests