How to Fetch Data from an API in React

Intermediate12 min interview
Skills tested:
Defining an async function inside useEffect and calling it immediatelyManaging loading, error, and data state for a fetch lifecycleHandling the cleanup function to cancel stale requests when the component unmounts or dependencies changeKnowing when to use React Query or SWR instead of manual useEffect fetchingFetching on a dependency (like an ID prop) and re-fetching when it changes

Advertisement

🧩 Scenario

Data fetching in useEffect is a foundational React skill. The basic pattern (fetch in effect, set state, show loading) is quick to learn. The production pattern (cleanup, error handling, re-fetch on dependency change, stale data prevention) is what interviewers probe. React Query and SWR address all these edge cases automatically, but understanding the manual pattern is a prerequisite for understanding what those libraries solve.

Architecture Walkthrough

The useEffect Fetch Pattern

Data fetching in React is a side effect: it happens outside the render cycle, produces a result asynchronously, and updates state when complete. useEffect is the hook for side effects. The fetch pattern inside useEffect is: define an async function, call it immediately, update the three state variables (data, loading, error) based on the outcome, and reset loading to false in a finally block regardless of success or failure.

The dependency array controls when the effect re-runs. An empty array [] means the effect runs once after the initial render (equivalent to componentDidMount). Including a prop like userId means the effect re-runs every time userId changes, enabling re-fetching when the data source changes.

The Async Effect Pattern

useEffect callbacks must be synchronous. An async function implicitly returns a Promise, and if the callback returns a Promise, useEffect ignores it and cannot use the cleanup return value. The correct pattern is to define the async function inside the effect and call it: async function fetchData() {...}; fetchData().

Cleanup and Race Conditions

When the component unmounts or a dependency changes before the current fetch completes, the in-flight request should be cancelled (or its result discarded) to prevent setting state on an unmounted component. The cleanup function (returned from useEffect) is the correct place for this. Use an AbortController to cancel the fetch itself, or use a cancelled boolean flag to ignore the result.


Key Code Explained

// Full pattern: loading, error, data, cleanup, and re-fetch on dependency
interface User {
  id: number;
  name: string;
  email: string;
}

function UserProfile({ userId }: { userId: number }) {
  const [user, setUser] = useState<User | null>(null);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    // Reset state when userId changes — prevents stale data flash
    setUser(null);
    setIsLoading(true);
    setError(null);

    const controller = new AbortController();  // for cleanup

    async function fetchUser() {
      try {
        const res = await fetch(
          `https://jsonplaceholder.typicode.com/users/${userId}`,
          { signal: controller.signal },  // passes AbortSignal to fetch
        );

        if (!res.ok) {
          throw new Error(`HTTP ${res.status}: ${res.statusText}`);
        }

        const data: User = await res.json();
        setUser(data);
      } catch (err) {
        // AbortError is thrown when controller.abort() is called — ignore it
        if (err instanceof Error && err.name !== 'AbortError') {
          setError(err.message);
        }
      } finally {
        setIsLoading(false);
      }
    }

    fetchUser();

    // Cleanup: cancel the in-flight request when userId changes or component unmounts
    return () => controller.abort();
  }, [userId]);  // re-runs whenever userId changes

  if (isLoading) return <UserSkeleton />;
  if (error) return <ErrorMessage message={error} />;
  if (!user) return null;

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


// Extracting to a custom hook for reuse
function useUser(userId: number) {
  const [user, setUser] = useState<User | null>(null);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    setUser(null);
    setIsLoading(true);
    setError(null);

    const controller = new AbortController();

    async function fetchUser() {
      try {
        const res = await fetch(`/api/users/${userId}`, {
          signal: controller.signal,
        });
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        const data: User = await res.json();
        setUser(data);
      } catch (err) {
        if (err instanceof Error && err.name !== 'AbortError') {
          setError(err.message);
        }
      } finally {
        setIsLoading(false);
      }
    }

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

  return { user, isLoading, error };
}

// Clean component — all logic in the hook
function UserCard({ userId }: { userId: number }) {
  const { user, isLoading, error } = useUser(userId);

  if (isLoading) return <Skeleton />;
  if (error) return <ErrorMessage message={error} />;
  if (!user) return null;

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


// How React Query replaces all of this
import { useQuery } from '@tanstack/react-query';

function UserCardWithQuery({ userId }: { userId: number }) {
  const { data: user, isLoading, error } = useQuery({
    queryKey: ['user', userId],
    queryFn: () =>
      fetch(`/api/users/${userId}`).then((res) => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json() as Promise<User>;
      }),
  });

  if (isLoading) return <Skeleton />;
  if (error) return <ErrorMessage message={(error as Error).message} />;
  if (!user) return null;

  return <div><h2>{user.name}</h2></div>;
}
// React Query automatically handles: caching, deduplication, background refetch,
// stale time, retry on error, loading/error states — all with less code

Extracting the fetch logic to a custom hook (useUser) is the correct production pattern. The component becomes display-only. The hook owns state and effects. This makes both the hook and the component independently testable and reusable.


Tradeoffs

ApproachCachingDeduplicationBackground refetchError retryCode size
Manual useEffectNoNoNoManualMore
React Query / SWRYesYesYesBuilt-inLess

What Interviewers Actually Check

  • Whether you define an async function inside useEffect instead of making the callback async
  • Whether you handle loading, error, and success states
  • Whether you reset loading in finally (not just in try)
  • Whether you add cleanup with AbortController or a cancelled flag
  • Whether you know React Query / SWR and when they are a better choice

Follow-Up Questions

  1. How would you implement pagination where clicking "Next" fetches the next page of results?
  2. How does Suspense for data fetching (React 18) differ from the manual loading state pattern?
  3. How does React Query's staleTime and gcTime control when cached data is refetched?
  4. How would you implement optimistic updates (show the result before the API confirms) with React Query?
  5. In Next.js App Router, how does fetching in a Server Component differ from fetching in a useEffect in a Client Component?

Common Candidate Mistakes

  • Making the useEffect callback itself async, which makes it return a Promise that React ignores and cannot use for cleanup
  • Not handling the error state, showing a blank UI when the fetch fails
  • Setting setIsLoading(false) only in the try block, leaving the spinner running forever when the fetch throws
  • Not returning a cleanup function, causing setState calls on unmounted components when the fetch completes after navigation
  • Not including userId in the dependency array, causing the component to display stale data after the prop changes

Interview Readiness Checklist

Before you leave this question, make sure you can answer:

  • Can you write a useEffect that fetches data with async/await and manages loading, error, and data state?
  • Can you explain why the useEffect callback cannot be async directly?
  • Can you add an AbortController cleanup to cancel the request on unmount or dependency change?
  • Can you re-fetch when a prop changes by including it in the dependency array?
  • Can you articulate what React Query or SWR adds over manual useEffect fetching?

Summary

Data fetching in React uses useEffect with an inner async function. The pattern is: define an async function inside the effect body, call it immediately, and manage three state variables: isLoading (starts true, set to false in finally), error (set in catch), and data (set in try). The finally block ensures loading is reset even when the fetch fails.

The dependency array controls re-fetching. An empty array means fetch once on mount. Including a prop like userId means re-fetch whenever that prop changes. When dependencies change before the current fetch completes, the cleanup function must cancel the in-flight request. Use AbortController with { signal: controller.signal } passed to fetch, and call controller.abort() in the cleanup return.

Extract the fetch logic to a custom hook to keep the component display-only and the fetch logic reusable and independently testable. For production applications, React Query or SWR replace manual useEffect fetching with built-in caching, deduplication, background refetching, and retry logic, significantly reducing boilerplate and eliminating entire categories of bugs.

Related Videos
Watch these videos to learn more about this topic
Frequently Asked Questions

Can I use async directly in the useEffect callback?

No. The useEffect callback must be synchronous (or return a cleanup function). Define an async function inside the effect and call it immediately. Marking the effect itself as async would make it return a Promise, which useEffect does not handle.

Advertisement


Stay Updated

Get the latest frontend challenges, interview questions and tutorials delivered to your inbox.

Advertisement