Blog

Blog's main page
EN
Picture of the article: React client-side data fetching with the `fetch` method, `Suspense` and the `use` hook

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?

  1. After the component mounts, the useEffect hook is called.

  2. The loading state is set to true — this may not be needed if the fetch is triggered by a user action.

  3. The error state is reset.

  4. The asynchronous getUserList function is called, and data fetching begins.

  5. 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:

  1. Displaying an error using an error boundary — This usually requires the react-error-boundary package.

  2. 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.