React client-side data fetching with the `fetch` method, `Suspense` and the `use` hook
This article presents a common pattern for fetching data on the client side using the latest version of React (v19.1 at the time of writing) in a way that is easy to implement, clear, and robust enough to get the job done.
The source code and a working example can be obtained from this repository.
Let’s assume we want to load a user list.
The classic way (so far)
Until recently, the most common and simplest way to fetch data with React has been to wrap the fetching process in a useEffect
and manage the loading state, error state, and data state with useState
. Something like this:
'use client'; // For Nextjs projects
const ReactBasicsFetchClassicPage = () => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | undefined>(undefined);
const [users, setUsers] = useState<User[]>([]);
useEffect(() => {
setLoading(true);
setError(undefined);
getUserList()
.then((users) => {
setUsers(users);
})
.catch((error: unknown) => {
const { message } = toErrorData(error);
setError(message);
})
.finally(() => {
setLoading(false);
});
}, []);
if (loading) {
return <Loading />;
}
if (error) {
return <ResultMessage type="error" content={error} className="justify-center" />;
}
return (
<div className="flex-1 flex flex-col gap-8">
<H4>
<FaReact />
Client-side data fetching with `useEffect` and `useState` (classic pattern)
</H4>
<UserList users={users} />
</div>
);
};
export default ReactBasicsFetchClassicPage;
What’s happening here?
After the component mounts, the
useEffect
hook is called.The
loading
state is set totrue
— this may not be needed if the fetch is triggered by a user action.The
error
state is reset.The asynchronous
getUserList
function is called, and data fetching begins.Once the data is fetched:
If everything goes well, the
users
state is updated.If something goes wrong, the
error
state is set with the caught error.The component re-renders and displays either the data or an error/loading state accordingly.
This pattern works fine but can lead to network waterfalls and requires managing multiple states manually.
A more modern approach
With React's Suspense
and the use
hook, the pattern can be made more declarative:
// No "await" here to ensure the Promise is resolved client side
const getUserListPromise = getUserList().catch((error) => {
const { message } = toErrorData(error);
return message;
});
const ReactBasicsFetchPage = () => {
return (
<div className="flex-1 flex flex-col gap-8">
<Suspense fallback={<Loading />}>
<FetchClient getUserListPromise={getUserListPromise} />
</Suspense>
</div>
);
};
export default ReactBasicsFetchPage;
'use client'; // For Nextjs projects
interface FetchClientProps {
getUserListPromise: Promise<User[] | string>;
}
const FetchClient = ({ getUserListPromise }: FetchClientProps) => {
const result = use(getUserListPromise);
if (typeof result === 'string') {
return <ResultMessage type="error" content={result} className="justify-center" />;
}
return <UserList users={result} />;
};
export default FetchClient;
What’s happening here?
According to the official React documentation, Suspense
:
Lets you display a fallback until its children have finished loading
Nice, right? React simplifies the display of a custom loading
component while data is being fetched using the fallback
prop. Suspense
automatically toggles between the fallback
and the resolved children when the promise resolves. The fetching process itself is managed with the use
hook:
use
is a React API that lets you read the value of a resource like a Promise or context.
In our case, it's a Promise. This Promise — the asynchronous data-fetching function — is passed as a prop and consumed using the use
hook, which suspends the component rendering until the Promise resolves.
Dealing with rejected Promises
There are two main options for handling rejected Promises:
Displaying an error using an error boundary — This usually requires the
react-error-boundary
package.Providing an alternative value with
Promise.catch
— This is the approach we've used here.
When using .catch()
, the value returned becomes the resolved value of the Promise. This allows us to return an error message and display it with minimal overhead.
Final thoughts
React's use
hook, combined with Suspense
, is a game-changer for client-side data fetching. It allows you to write cleaner, more declarative components without the clutter of multiple states or side effects.
While is still has some limitations (like lack of built-in retry or caching), it's a powerful tool for straightforward client-side data fetching.