Fossils⚛ïļ React PatternsAsync & Data Fetching Interview Questions
ðŸĶ–DinosaurReactData FetchingRace ConditionsAbortControllerAsync

Async & Data Fetching Interview Questions

Where to make API calls, race conditions, stale data — the async questions that test your real-world experience.

Async & Data Fetching Interview Questions

These questions reveal whether you've built real applications. Anyone can fetch data in a useEffect — but handling race conditions, stale data, and error states cleanly is what separates people who've shipped production apps from people who've only followed tutorials.


Q14: Where Should API Calls Be Made in React?

Interview Question: "Where do you make API calls in a React component? Is useEffect the right place?"

The Simple Explanation

Think of it like ordering food. There are two scenarios:

  1. You sit down at a restaurant and the waiter brings you bread automatically — that's like fetching data when a component mounts. The data should just "be there" when the page loads.

  2. You tap a button on a menu tablet to order a specific dish — that's like fetching data in response to a user action. Something specific happened, so you fetch specific data.

These two scenarios need different approaches.

Approach 1: useEffect for "Load on Mount" Data

When your component needs data to display itself, useEffect is the standard pattern:

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(() => {
    let cancelled = false;
 
    async function loadUser() {
      try {
        setLoading(true);
        const data = await fetchUser(userId);
        if (!cancelled) {
          setUser(data);
          setError(null);
        }
      } catch (err) {
        if (!cancelled) {
          setError(err instanceof Error ? err : new Error('Failed to load'));
        }
      } finally {
        if (!cancelled) setLoading(false);
      }
    }
 
    loadUser();
    return () => { cancelled = true; };
  }, [userId]);
 
  if (loading) return <Spinner />;
  if (error) return <ErrorMessage error={error} />;
  return <div>{user?.name}</div>;
}

Notice the cancelled flag — we'll cover why that's critical in Q15.

Approach 2: Event Handlers for User-Driven Fetches

When the user triggers the fetch (submit a form, click a button, search), use the event handler directly:

function SearchBar() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState<SearchResult[]>([]);
  const [loading, setLoading] = useState(false);
 
  const handleSearch = async () => {
    setLoading(true);
    try {
      const data = await searchAPI(query);
      setResults(data);
    } catch (err) {
      console.error('Search failed:', err);
    } finally {
      setLoading(false);
    }
  };
 
  return (
    <div>
      <input value={query} onChange={e => setQuery(e.target.value)} />
      <button onClick={handleSearch} disabled={loading}>
        Search
      </button>
      {results.map(r => <ResultCard key={r.id} result={r} />)}
    </div>
  );
}

Why not useEffect here? Because the fetch is caused by a user action, not by a dependency changing. Using useEffect would mean the search fires on every keystroke (unless you debounce), and you'd need extra state (submitted) to track when to actually search. The event handler is simpler and more direct.

Approach 3: Data Fetching Libraries (The Production Answer)

In real production apps, you'd rarely write raw useEffect for data fetching. Libraries handle caching, deduplication, retry, and stale data automatically:

// React Query / TanStack Query
function UserProfile({ userId }: { userId: string }) {
  const { data: user, isLoading, error } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
  });
 
  if (isLoading) return <Spinner />;
  if (error) return <ErrorMessage error={error} />;
  return <div>{user?.name}</div>;
}

The Decision

ScenarioWhere to Fetch
Data needed on page loaduseEffect (or data-fetching library)
Data triggered by user actionEvent handler
Data that needs caching/revalidationReact Query, SWR, or similar
Server-rendered appServer Components or loader functions

The One-Liner That Impresses

"Data fetching on mount belongs in useEffect with proper cleanup, user-driven fetches belong in event handlers, and production apps should use a data-fetching library like React Query that handles caching, deduplication, and race conditions automatically — useEffect is the low-level primitive, not the final solution."

Common Follow-Up Questions

"Can you fetch data in a server component?"

Yes — in frameworks like Next.js, server components can use async/await directly. The data is fetched on the server and the HTML is sent to the client. No useEffect needed, no loading state, no client-side waterfall.

"What's wrong with fetching in the component body (not in useEffect)?"

Component functions run during the render phase, which React can call multiple times. An API call in the body would fire on every render and potentially multiple times per render in concurrent mode. useEffect ensures it runs once after the render commits to the DOM.

Red Flags in Your Answer

  • Putting all API calls in useEffect regardless of trigger
  • Not handling loading and error states
  • Not mentioning cleanup / cancellation
  • Not knowing about data-fetching libraries for production use
  • Fetching in the component body instead of useEffect or event handlers

Q15: How Do You Handle Race Conditions in React?

Interview Question: "What is a race condition in React data fetching, and how do you prevent it?"

The Simple Explanation

Think of it like ordering from two different delivery apps at the same time. You order pizza at 6:00 PM (fast delivery) and sushi at 6:05 PM (slow delivery). But the pizza place is busy, so the sushi arrives first at 6:30, and the pizza arrives at 6:45.

Now your table shows pizza — but you last ordered sushi. The slower first request "won the race" against the faster second request.

In React, this happens when you fire multiple async requests and an earlier (now stale) request resolves after a later one.

The Race Condition in Action

function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState(null);
 
  useEffect(() => {
    // BUG: No cleanup. If userId changes quickly:
    // Request 1 (userId="alice") starts... takes 3 seconds
    // Request 2 (userId="bob") starts... takes 1 second
    // Request 2 resolves first → shows "bob" ✓
    // Request 1 resolves second → overwrites with "alice" ✗
    fetchUser(userId).then(setUser);
  }, [userId]);
 
  return <div>{user?.name}</div>;
}

The user navigated to Bob's profile, but they see Alice's data because Alice's request was slower and resolved last.

Fix 1: The Boolean Flag (Simple and Effective)

useEffect(() => {
  let cancelled = false;
 
  fetchUser(userId).then(data => {
    if (!cancelled) setUser(data);
  });
 
  return () => { cancelled = true; };
}, [userId]);

When userId changes, the cleanup function runs and sets cancelled = true for the previous effect. When the old request finally resolves, it checks cancelled and silently discards the stale data.

Think of it like putting a "RETURN TO SENDER" stamp on a letter before it arrives. The letter still travels, but you don't open it.

Fix 2: AbortController (Cancels the Request Itself)

The boolean flag lets the request finish and just ignores the result. AbortController actually cancels the HTTP request, saving bandwidth and server resources.

useEffect(() => {
  const controller = new AbortController();
 
  async function loadUser() {
    try {
      const response = await fetch(`/api/users/${userId}`, {
        signal: controller.signal,
      });
      const data = await response.json();
      setUser(data);
    } catch (err) {
      if (err instanceof DOMException && err.name === 'AbortError') {
        return; // Request was cancelled — this is expected, not an error
      }
      setError(err);
    }
  }
 
  loadUser();
  return () => controller.abort();
}, [userId]);

Think of it like calling the restaurant and saying "cancel my order" instead of just ignoring the food when it arrives. The kitchen stops cooking.

Fix 3: Request Deduplication

If the same data is requested multiple times (multiple components loading the same user), deduplicate at the fetch layer:

const pendingRequests = new Map<string, Promise<any>>();
 
function deduplicatedFetch<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
  if (pendingRequests.has(key)) {
    return pendingRequests.get(key)!;
  }
 
  const promise = fetcher().finally(() => {
    pendingRequests.delete(key);
  });
 
  pendingRequests.set(key, promise);
  return promise;
}
 
// Usage
useEffect(() => {
  const controller = new AbortController();
 
  deduplicatedFetch(`user-${userId}`, () =>
    fetch(`/api/users/${userId}`, { signal: controller.signal }).then(r => r.json())
  ).then(data => setUser(data));
 
  return () => controller.abort();
}, [userId]);

Why Data-Fetching Libraries Win Here

React Query, SWR, and similar libraries handle ALL of this for you:

const { data: user } = useQuery({
  queryKey: ['user', userId],
  queryFn: () => fetchUser(userId),
  // Automatic: cancellation, deduplication, stale data handling, retry
});

Behind the scenes, React Query:

  • Cancels in-flight requests when userId changes
  • Deduplicates identical requests from different components
  • Returns cached data instantly while revalidating in the background
  • Retries failed requests with exponential backoff

The One-Liner That Impresses

"Race conditions happen when a stale async response overwrites fresher data — the fix is either a boolean cancelled flag in useEffect cleanup for ignoring stale results, or AbortController for actually cancelling the HTTP request. Production apps should use React Query or SWR, which handle cancellation, deduplication, and retry automatically."

Common Follow-Up Questions

"Does the boolean flag waste bandwidth?"

Yes — the request still completes; you just ignore the result. AbortController is better because it cancels the request entirely. For most apps the wasted bandwidth is negligible, but for large payloads or metered connections, AbortController is worth the extra code.

"Can race conditions happen outside of useEffect?"

Yes — any time you have multiple overlapping async operations writing to the same state. A search-on-type input without debouncing can have the same problem. The fixes are the same: cancel or ignore stale responses.

Red Flags in Your Answer

  • Not knowing what a race condition is
  • Writing useEffect with no cleanup for async operations
  • Only mentioning the boolean flag but not AbortController
  • Not mentioning data-fetching libraries as the production solution
  • Thinking React handles this automatically (it doesn't)

Q16: What Is the Stale Data Problem?

Interview Question: "What is stale data in React, and how do you solve it?"

The Simple Explanation

Think of it like checking the weather app. You open it at 8 AM and see "Sunny, 72°F." You leave the app open. At 2 PM, a storm rolls in — but your app still shows "Sunny, 72°F" because it only fetched the data once, hours ago.

That's stale data — your UI shows information that was accurate when fetched but is no longer current.

How Stale Data Happens in React

Scenario 1: Fetch-once-and-forget

function StockPrice({ symbol }: { symbol: string }) {
  const [price, setPrice] = useState<number | null>(null);
 
  useEffect(() => {
    fetchStockPrice(symbol).then(setPrice);
    // Fetches once. Price is immediately stale and gets more stale every second.
  }, [symbol]);
 
  return <p>{symbol}: ${price}</p>;
}

Scenario 2: Cache without revalidation

You cache API responses to avoid redundant fetches. But now when the user navigates back to a page, they see cached data from 10 minutes ago. Has the data changed? They have no way to know.

Scenario 3: Multiple browser tabs

User has your app open in two tabs. They update their profile in Tab A. Tab B still shows the old profile until they manually refresh.

Solution 1: Polling (Simple but Wasteful)

Fetch the data at regular intervals:

function StockPrice({ symbol }: { symbol: string }) {
  const [price, setPrice] = useState<number | null>(null);
 
  useEffect(() => {
    const fetchPrice = () => fetchStockPrice(symbol).then(setPrice);
 
    fetchPrice(); // Initial fetch
 
    const interval = setInterval(fetchPrice, 5000); // Refresh every 5 seconds
    return () => clearInterval(interval);
  }, [symbol]);
 
  return <p>{symbol}: ${price}</p>;
}

Simple, but you're making API calls even when the data hasn't changed. Fine for a stock ticker, wasteful for a user profile.

Solution 2: Stale-While-Revalidate (SWR Pattern)

This is the pattern that React Query and the SWR library are named after. The idea:

  1. Return the cached (stale) data immediately — so the UI is never empty
  2. Fetch fresh data in the background — so the UI updates shortly after
  3. Replace stale data with fresh data when the fetch completes
User navigates to page:
  1. Show cached data instantly (might be stale)
  2. Fire background fetch
  3. When fetch resolves, update the UI with fresh data

The user sees content immediately (good UX) and gets fresh data moments later (good accuracy).

function UserProfile({ userId }: { userId: string }) {
  const { data: user, isStale, isFetching } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
    staleTime: 5 * 60 * 1000, // Data is "fresh" for 5 minutes
    // After 5 minutes, data is considered stale.
    // Next time this query is used, it returns stale data
    // and refetches in the background.
  });
 
  return (
    <div>
      <h1>{user?.name}</h1>
      {isFetching && <small>Updating...</small>}
    </div>
  );
}

Solution 3: Revalidation Triggers

Instead of polling at fixed intervals, revalidate when something meaningful happens:

const { data: user } = useQuery({
  queryKey: ['user', userId],
  queryFn: () => fetchUser(userId),
  refetchOnWindowFocus: true,  // Refetch when user switches back to tab
  refetchOnReconnect: true,    // Refetch when internet reconnects
  refetchOnMount: true,        // Refetch when component mounts (if stale)
});

This covers the multi-tab scenario: when the user switches from Tab A (where they updated) to Tab B, the refetchOnWindowFocus trigger fires and Tab B gets fresh data.

Solution 4: Optimistic Updates

For user-driven mutations, don't wait for the server to confirm — update the UI immediately and reconcile later:

const mutation = useMutation({
  mutationFn: updateUserName,
  onMutate: async (newName) => {
    // Cancel any in-flight refetches
    await queryClient.cancelQueries({ queryKey: ['user', userId] });
 
    // Snapshot the previous value
    const previousUser = queryClient.getQueryData(['user', userId]);
 
    // Optimistically update the cache
    queryClient.setQueryData(['user', userId], (old: User) => ({
      ...old,
      name: newName,
    }));
 
    return { previousUser }; // Return snapshot for rollback
  },
  onError: (_err, _newName, context) => {
    // Rollback on error
    queryClient.setQueryData(['user', userId], context?.previousUser);
  },
  onSettled: () => {
    // Refetch to ensure server and client are in sync
    queryClient.invalidateQueries({ queryKey: ['user', userId] });
  },
});

Think of it like changing your display name on a social media app. The UI updates instantly — you see your new name immediately. In the background, the server processes the change. If the server rejects it (name taken, too long), the UI rolls back to the old name.

The Stale Data Spectrum

StrategyFreshnessComplexityBest For
Fetch onceLowLowStatic data (about page)
PollingHighLowReal-time data (stocks, scores)
SWRMedium-HighMediumMost app data
WebSocketReal-timeHighChat, live collaboration
Optimistic updatesPerceived instantHighUser mutations

The One-Liner That Impresses

"Stale data is cached information that no longer reflects the server's state — the stale-while-revalidate pattern solves this by showing cached data instantly for fast UX while refetching in the background, combined with revalidation triggers like window focus and reconnection to keep multi-tab and offline scenarios fresh without polling."

Common Follow-Up Questions

"How do you decide the staleTime for a query?"

It depends on how critical freshness is. A user's profile? 5 minutes is fine. Stock prices? 0 seconds (always refetch). Feature flags? 30 minutes. There's no universal answer — it's a UX decision based on how much staleness users can tolerate.

"What's the difference between staleTime and cacheTime in React Query?"

staleTime = how long data is considered fresh (won't refetch). gcTime (formerly cacheTime) = how long inactive data stays in the cache before garbage collection. Data can be stale but still cached — you show the stale cache while refetching. Data that's been garbage collected requires a full loading state.

"How do you handle stale data without a library?"

You'd need to build your own cache with timestamps, implement revalidation logic, manage cache invalidation after mutations, and handle window focus events manually. It's doable but React Query exists specifically because this is complex and error-prone to build from scratch.

Red Flags in Your Answer

  • Saying "just fetch the data every time" without considering caching
  • Not knowing what stale-while-revalidate means
  • Recommending polling for everything
  • Not mentioning React Query, SWR, or similar libraries
  • Ignoring the multi-tab / window-focus revalidation scenario
  • Not connecting optimistic updates to the stale data problem