Data Fetching in React Interviews
A comprehensive guide to efficiently fetching data in React, covering client-side and server-side techniques, dynamic queries, error handling, caching, and advanced optimizations with query libraries
Fetching data is a fundamental part of building web applications. In React, managing data fetching efficiently is crucial to creating responsive and performant applications. Whether retrieving data from an API, database, or another source, React provides multiple approaches to handle data fetching effectively.
Data can be fetched either on the server (server-side rendering) or on the client via fetch
and then rendered in the browser (client-side rendering).
Client-side data fetching
The built-in fetch
API is a straightforward way to make HTTP requests in JavaScript. Here's a simple example using the useEffect
hook to fetch data from an API:
import { useState, useEffect } from 'react';function Page() {const [data, setData] = useState(null);const [loading, setLoading] = useState(true);const [error, setError] = useState(null);useEffect(() => {fetch('https://jsonplaceholder.typicode.com/posts').then((response) => {if (!response.ok) {throw new Error('Error response');}return response.json();}).then((data) => {setData(data);setLoading(false);}).catch((error) => {setError(error);setLoading(false);});}, []);if (loading) {return <p>Loading...</p>;}if (error) {return <p>Error: {error.message}</p>;}return (<ul>{data.map((post) => (<li key={post.id}>{post.title}</li>))}</ul>);}
This component calls an API on mount, fetches the data, and renders it to the screen. It also shows a loading message while the data is being fetched and error message if an error was encountered. These are all great for user experience.
Dynamic client-side data fetching
However, often you will need to fetch data based on a dynamic parameter, such as a blog post slug or an autocomplete search query.
The following "live search" example fetches data based on a user-provided search term as you type:
import { useState } from 'react';import axios from 'axios';function SearchResults() {const [query, setQuery] = useState('');const [data, setData] = useState(null);const [loading, setLoading] = useState(false);const [error, setError] = useState(null);async function fetchData(query) {if (!query) {return;}setLoading(true);setError(null);try {const response = await fetch(`https://jsonplaceholder.typicode.com/posts?q=${encodeURIComponent(query,)}`,);if (!response.ok) {throw new Error('Error response');}const data = await response.json();setData(data);} catch (err) {setError(err);}setLoading(false);}useEffect(() => {fetchData(query);}, [query]);return (<div><inputtype="text"value={query}onChange={(event) => setQuery(event.target.value)}placeholder="Enter search term"/>{loading && <p>Loading...</p>}{error && <p>Error: {error.message}</p>}{data && (<ul>{data.map((result) => (<li key={result.id}>{result.title}</li>))}</ul>)}</div>);}
Spot any problem with the example above? On first glance it might look fine, but there are actually many issues and areas of improvements:
A fetch
request is made per keystroke
Since there's no submit button, the fetchData()
function is called on every change in the <input>
. If a user enters 'tomato'
, a total of 6 HTTP requests will be made! This is quite redundant considering the user probably only cares about the results for 'tomato'
.
One way to resolve this is to debounce the query so that the API request is only made after the user has stopped typing for a specific duration.
Race conditions
Wastefulness of multiple requests aside, a more serious problem is that the displayed results aren't even for the current search term. Race conditions can occur because when multiple requests are made, the server can return the results in any order. The response for the term 'toma'
can arrive later than the response for the term 'tomato'
, but the code simply displays the results of the latest request, not necessarily the request of the current search term.
This problem can be fixed/improved in multiple ways:
- Debouncing: Debouncing ensures that requests are sent only after a period of inactivity, which reduces the chance of race conditions. However, if the request latency is longer than the debounce duration, then race conditions can still occur. Debouncing is not a foolproof approach.
- Cancelling previous requests: Use
AbortController
to cancel in-flight API requests when a new one is made. - Ignore/discard outdated responses: Before displayed data, determine if the response is for the current query, and ignore/discard the responses that aren't relevant.
Setting state after unmount
If the user navigates away from the page and the response is returned, the code will attempt to call setData
even though the component is no longer in the DOM and React will log a warning "Can't perform a React state update on an unmounted component." This can be fixed by ensuring the component is still mounted before updating state by using a cleanup function in useEffect
to set an isMounted
flag to false
or using AbortController
s for fetch requests.
useEffect(() => {let isMounted = true;fetch('https://jsonplaceholder.typicode.com/posts/1').then((response) => response.json()).then((data) => {if (isMounted) {setData(data);}});return () => {isMounted = false;};}, []);
Redundant duplicate requests
This happens when a user types a query, receives results, and then retypes the same query, triggering another API request when the data is already available.
Example: If a user types 'tomato'
, deletes it, and then types 'tomato'
again, the API is queried twice despite already having the results.
Caching is the solution to this issue. An example is to use a Map<query, results>
as the cache. Before making an API call, check if the results already exist in the cache. If they do, use them instead of fetching again.
Data mutations
Data fetching isn't only limited to querying, there's also mutations. A data mutation refers to any operation that modifies data on the server, such as creating, updating, or deleting records in a database via an API.
Optimistic updates
The typical steps in a data mutation with any optimizations:
- Trigger the API request (POST, PUT, DELETE)
- Show a loading state while the request is in progress
- Wait for the response from the server
- In success cases, handle the response and update UI where relevant
- Handle errors appropriately (e.g., show an error message)
This flow is slow because the user must wait for the server response before seeing changes. This is where optimistic updates can help.
Optimistic updates are a technique used in data mutations where the UI updates before receiving a response from the server. This makes the application feel faster and more responsive by assuming the mutation will succeed.
- Update the UI immediately with the assumed successful change
- Send the API request in the background
- If the request succeeds, keep the UI as it is
- If the request fails, rollback the UI to its previous state and show an error message
Optimistic updates reduce perceived latency and improves user experience.
const handleLike = async () => {setLike((count) => count + 1); // Update UI optimisticallytry {await fetch('/api/posts/4/like', {method: 'POST',});} catch (error) {setLike((count) => count - 1); // Rollback on failure}};
Optimistic updates can be used for mutations where the success scenario is known beforehand, such as liking a post, adding an item to a cart, or editing a comment.
Invalidating cache
If the UI relies on cached data, the cache should be invalidated either by refetching the data or merging the mutation response with the cached data (not possible for certain mutations).
Query libraries
By now you probably agree with me that data fetching is such a huge pain to implement properly. But not to worry, query libraries to the rescue!
Query libraries are specially designed to handle data fetching, caching, synchronization, and state management in front end applications. They simplify making API requests while optimizing performance and user experience. Popular examples include TanStack Query, useSWR, and Apollo Client (for GraphQL).
The following example uses TanStack Query. See how easy it is to fetch data when they're written in a declarative manner!
import { useQuery } from '@tanstack/react-query';import axios from 'axios';function DataFetchingComponent() {const { data, isLoading, error } = useQuery({queryKey: ['posts'],queryFn: async () => {const response = await axios.get('/api/posts');return response.data;},staleTime: 5000, // Cache data for 5 seconds});if (isLoading) {return <p>Loading...</p>;}if (error) {return <p>Error: {error.message}</p>;}return (<ul>{data.map((post) => (<li key={post.id}>{post.title}</li>))}</ul>);}
Advantages
Feature | useEffect + fetch | Query Libraries |
---|---|---|
Caching | No caching | Automatic caching |
Error handling | Needs try /catch | Built-in retries |
Optimistic updates | Requires manual rollback | Automatic rollback on failure |
Pagination | Requires additional logic | Built-in support |
Background updates | Requires manual polling | Handles updates in background |
Refetching | Manual | Automatic |
Server-side data fetching and server-side rendering (SSR)
Server-side data fetching is a technique where data is retrieved from a database or API before rendering the page on the server (SSR), rather than fetching it on the client after the initial load. SSR improves performance, SEO, and user experience by delivering pre-rendered, data-filled pages to the client.
Metaframeworks like Next.js make server-side data fetching easy with functions like getServerSideProps
/ server components, which runs on the server before sending the HTML to the browser. Unlike client-side fetching, where a loading state is visible while waiting for data, server-side fetching ensures that the user sees the fully rendered page immediately.
This is particularly useful for performance-sensitive pages, SEO-sensitive pages, personalized dashboards, and dynamic content that depends on request-specific data (e.g., authentication or geolocation).
With the exception of take-home assignments, most React coding interview questions will be using client-side data fetching rather than server-side data fetching.
However, CSR vs SSR is a common discussion topic during system design rounds and you should know the benefits of each and when to use which.
What you need to know for interviews
- Basic data fetching:
- Using
useEffect
anduseState
for fetching data in functional components - Handling loading and error states
- Understanding dependency arrays (
[]
for on-mount fetch,[query]
for fetch-on-change) - Avoiding infinite loops due to missing dependencies
- Cleaning up side effects (e.g., aborting fetch requests to prevent setting state on unmount)
- Using
- Issues with basic data fetching approach: Although you probably won't be asked to implement optimized data fetching approaches, you should know what the issues are and how to fix them on a high level
Key interview questions
- How do you fetch data in React?
- What are the benefits of using TanStack Query over
useState
anduseEffect
? - How do you prevent redundant API requests in a live search?
- What are optimistic updates, and how do they improve performance?
- How do you handle errors and retries in API calls?
- How do you cancel an API request if a component unmounts?
Practice questions
Quiz:
- How do you handle asynchronous data loading in React applications?
- Explain server-side rendering of React applications and its benefits?
- What are some common pitfalls when doing data fetching in React?
Coding: