DF[dot]DEV

Fetching data in React should be easier

TL;DR: Use TanStack Query.

Originally this post was going to be dedicated to the new use functionality introduced in React 19. While reading about it I was intrigued about the ability to wait on the results of a Promise inside a component that would be placed into a suspended state while the promise was pending. Prior to this functionality, the typical way to fetch data within a component was with a useEffect call, which as any seasoned React developer knows introduces multiple ways to make mistakes if you’re not familiar with it.

My goal for this post is to create a small example project that makes use of data fetching in both React 18 (via the traditional useEffect method) and in React 19 (via the new use functionality). Additionally, I’ll include an example of the project in SolidJS for comparison against both methods in React to see how other frameworks handle data loading. Alright, let’s dive in!

Our example project

Starting with the requirements for our example project, what we want to do is leverage the built-in functionality within React to fetch a list of mock users that will be randomly generated by @faker-js/faker and display them in a table. A pagination component must exist so that we can navigate to the previous and next result pages.

interface IMockUsersResponse {
pagination: {
current_page: number;
total_pages: number;
records_total: number;
records_per_page: number;
};
users: {
id: string;
email: string;
name: string;
}[];
}

React implementation with useEffect

Our first attempt at this project is going to use functionality available in React 18. Without using an external library for data loading, the most common way to load data with the stock functionality is with useEffect, so that’s how we’re going to tackle it here.

Make sure to open the completed example so you can view all the code as we’re walking through this section.

Encapsulating the fetching logic into a custom hook

Separation of concerns is an important concept in development and when working with React we can keep our mock data loading and the component rendering separate by extracting the data loading logic into a custom hook. Not only does this keep code within the component cleaner, it has the added benefit of re-usability in the case that another component needs to make the same data request.

Our custom hook is going to have the ability to track the request status, any error encountered, and the response received. It’s going to take a single page argument that will be added to the useEffect dependency array so that the mock users data is re-fetched whenever the page value changes. Let’s take a look at how to implement the custom hook:

useUsersList.ts
import { useEffect, useState } from 'react';
import getMockUsers, { IMockUsersResponse } from '../api/getMockUsers';
import { getErrorObject } from '../util/errors';
function useUsersList(page = 1) {
const [status, setStatus] = useState<'loading' | 'error' | 'loaded'>(
'loading'
);
const [error, setError] = useState<Error | null>(null);
const [data, setData] = useState<IMockUsersResponse | null>(null);
useEffect(() => {
let ignore = false;
const fetchData = async () => {
setError(null);
setStatus('loading');
try {
const response = await getMockUsers(page);
if (!ignore) {
setData(response);
setStatus('loaded');
}
} catch (err) {
if (!ignore) {
setData(null);
setError(getErrorObject(err));
setStatus('error');
}
}
};
fetchData();
return () => {
ignore = true;
};
}, [page]);
return {
status,
error,
pagination: data?.pagination ?? {
current_page: 1,
total_pages: 1,
records_per_page: 10,
records_total: 0,
},
users: data?.users ?? [],
};
}
export default useUsersList;

Alright, that’s not too bad. If you’ve been using React for a while you’ve likely seen a lot of code like this. One important thing to note is the use of the ignore variable. This is a technique that comes from the official React docs regarding fetching data with effects that prevents stale data from being used to set state if an older request arrives after a newer one. This is important while using StrictMode where the effect will be re-run immediately after it’s initial invocation. This won’t prevent duplicate network requests from occurring, but it will stop the outdated response from populating the state values.

The root component in React 18

Now that we’ve completed our custom hook for fetching the users list, let’s see how it all fits together in the root component that renders the Pagination and UsersTable components:

App.tsx
import { useState } from 'react';
import Pagination from './components/Pagination';
import UsersTable from './components/UsersTable';
import useUsersList from './hooks/useUsersList';
function App() {
const [currentPage, setCurrentPage] = useState(1);
const { status, error, pagination, users } = useUsersList(currentPage);
return (
<>
<Pagination
currentPage={pagination.current_page}
totalPages={pagination.total_pages}
disabled={status !== 'loaded'}
onChangePage={setCurrentPage}
/>
{status === 'loading' && <p>Loading...</p>}
{status === 'error' && (
<p>Error: {error?.message ?? 'An error occurred'}</p>
)}
{status === 'loaded' && <UsersTable users={users} />}
</>
);
}
export default App;

Again, not that bad. The rendering logic is fairly simple and by utilizing the status value from the custom hook we can keep the conditional rendering easy to read without the need to have multiple if (...) conditionals outside the main return statement.

React implementation with use

As part of the release of React 19 in early December of 2024, a new use API was introduced. Aside from having an ambiguous naming convention, it’s important to note that use is an API and not a hook. As such, it doesn’t need to follow the rules of hooks and can be called conditionally. While use serves a dual purpose of suspending a component while waiting on promises to resolve/reject and accessing context values, we’re going to focus exclusively on the promises functionality since it should be useful for our example project.

Before jumping into implementing use in our project, it’s important to understand how suspense works in React as it will be critical for how we’re about to handle the data fetching promise. Consider this following abbreviated example:

parent-component.jsx
function ParentComponent() {
return (
<ErrorBoundary>
<Suspense fallback={() => <span>Loading...</span>}>
<ChildComponent />
<Suspense>
</ErrorBoundary>
);
};
child-component.jsx
function ChildComponent() {
const dataPromise = getDataFetchingPromise(...);
const fetchResponse = use(dataPromise);
return (
<>
<p>Response value: {fetchResponse.value}</p>
</>
);
}

When ChildComponent suspends as a result of calling use on an unfulfilled promise, the entire component is removed from the tree and replaced with the component returned from the fallback render prop. When that promise resolves, ChildComponent is re-mounted. Assuming that the value returned from getDataFetchingPromise is not cached, do you notice any problems with the above code?

If you think that creating the promise inside ChildComponent is an issue, you’re absolutely right. As a result of ChildComponent being re-mounted, the promise is created as a new value, and puts the suspense phase into an infinite loop. React should detect this issue and issue a warning while in development, but it’s not 100% effective.

The solution here is to create the promise outside of ChildComponent and pass it in as a prop. For best results, utilize useMemo and only re-create the promise when its dependencies change:

parent-component.jsx
function ParentComponent() {
const dataPromise = useMemo(
() => getDataFetchingPromise(...),
[...]
);
return (
<ErrorBoundary>
<Suspense fallback={() => <span>Loading...</span>}>
<ChildComponent dataPromise={dataPromise} />
<Suspense>
</ErrorBoundary>
);
};
child-component.jsx
function ChildComponent({ dataPromise }) {
const dataPromise = getDataFetchingPromise(...);
const fetchResponse = use(dataPromise);
return (
<>
<p>Response value: {fetchResponse.value}</p>
</>
);
}

Now that we’ve covered how to handle promises for components that utilize them with use, let’s take a look at how to implement data fetching for our project using the new functionality in React 19.

Just like the React 18 example, you can view the completed example to follow along as we proceed.

Warning!

The following code is by no means ideal and is not something I’d personally want to see in a project. There are better ways to handle data loading in React and the entire process of putting this example together felt like a square peg in a round hole type of situation. While it works, this code is a perfect example of “just because we can, doesn’t mean we should”.

Managing the data loading promise

In order to take advantage of Suspense and the fallback loading functionality it provides, all components that rely on the state of the data loading promise or the response it returns will need access to the promise. There’s two ways to do this:

  1. Send the promise value down through the component tree via prop drilling.
  2. Create a context provider that contains the promise value and access it within the components that require it.

For this example I opted to go with the context provider option. While this project is small and the prop drilling wouldn’t be too deep or difficult to manage, I feel that a context provider serves most situations like this in a better way. If the project requirements were to increase in complexity somehow, making those adjustments with a context provider would be easy as opposed to re-wiring the paths used by prop drilling.

Here’s how the completed context provider looks:

UsersContext.tsx
import { createContext, use, useMemo, useState } from 'react';
import getMockUsers, { IMockUsersResponse } from '../api/getMockUsers';
export interface IUsersContext {
currentPage: number;
setCurrentPage: React.Dispatch<React.SetStateAction<number>>;
dataPromise: Promise<IMockUsersResponse>;
}
const UsersContext = createContext<IUsersContext | null>(null);
export function UsersContextProvider({ children }: React.PropsWithChildren) {
const [currentPage, setCurrentPage] = useState(1);
const dataPromise = useMemo(() => getMockUsers(currentPage), [currentPage]);
return (
<UsersContext.Provider
value={{
currentPage,
setCurrentPage,
dataPromise,
}}
>
{children}
</UsersContext.Provider>
);
}
export function getUsersContext() {
const context = use(UsersContext);
if (!context) {
throw new Error(
'getUsersContext must be used within a UsersContextProvider'
);
}
return context;
}

In addition to the getUsersPromise value, the context provider also contains currentPage and setCurrentPage values that can be accessed where needed.

I also created a getUsersContext function that will return the context object or throw an error if it is accessed in a component that is not a descendent of <UsersContextProvider>. Typically this would be done using a custom hook and would be called something like useUsersContext. However, since we’re using use to access the context object here, and use isn’t a hook, it doesn’t make sense to semantically identify this function as a hook since it doesn’t need to follow the rules of hooks.

Finally, let’s make sure that the root of our application is wrapped in <UsersContextProvider>. Here’s how it should look:

main.tsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.tsx';
import { UsersContextProvider } from './context/UsersContext.tsx';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<UsersContextProvider>
<App />
</UsersContextProvider>
</StrictMode>
);

Building the parent component and implementing the UsersTable component

Now that the context provider is created and getting state into the components that require it should be easy, this is how I structured the parent component:

App.tsx
import { Suspense } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import Pagination from './components/Pagination';
import { getErrorObject } from './util/errors';
import UsersTable from './components/UsersTable';
function App() {
return (
<>
<Pagination />
<ErrorBoundary
fallbackRender={({ error }) => (
<p>Error: {getErrorObject(error).message}</p>
)}
>
<Suspense fallback={<p>Loading...</p>}>
<UsersTable />
</Suspense>
</ErrorBoundary>
</>
);
}
export default App;

The easy part of this was implementing the UsersTable component. Since it has a binary state of either displaying “loading…” or the table of users, it fell nicely into the standard Suspense pattern. An ErrorBoundary wraps the entire thing to display an error instead of the table if needed. If this was my complete experience with the new functionality I would have been quite happy with it. Unfortunately, I quickly ran into issues where this pattern became extremely rigid and difficult to make use of.

The hard part: implementing the Pagination component

Unlike the users table with its easy to manage binary state, the pagination component has slightly more nuanced needs. To re-cap, the pagination component needs to disable the navigation buttons while the request is loading, disable the navigation buttons when there are no more pages available in the specified direction, and maintain the ”# of #” status text depending on the current page and total pages values. These are not advanced requirements by any means and I was surprised to see how difficult it was to build this with the new React functionality.

In order to get this working as expected, it was necessary to break out the pagination buttons and status text into <PaginationButton> and <PaginationStatusText> components respectively. The reason for this decision was to isolate which components would suspend when they accessed the data loading promise via use. It would be ideal to access the result of the data loading promise in the <Pagination> component and utilize the values there so that the component break out wouldn’t be needed. However, that would cause the entire <Pagination> component to suspend and display the fallback content, leaving us without any way to temporarily disable the navigation buttons or maintain the last visible status text. It’s possible that useTransition might have been of use here, but I had significant trouble getting it to work as expected.

Here is how the <PaginationButton> component ended up:

PaginationButton.tsx
import { use } from 'react';
import { getUsersContext } from '../context/UsersContext';
const PaginationButton: React.FC<{ type: 'prev' | 'next' }> = ({ type }) => {
const usersContext = getUsersContext();
const response = use(usersContext.dataPromise);
const isDisabled =
(type === 'prev' && usersContext.currentPage < 2) ||
(type === 'next' &&
usersContext.currentPage >= response.pagination.total_pages);
return (
<button
disabled={isDisabled}
onClick={() =>
usersContext.setCurrentPage((curPage) =>
type === 'prev' ? curPage - 1 : curPage + 1
)
}
>
{type === 'prev' ? 'Prev' : 'Next'}
</button>
);
};
export default PaginationButton;

Here’s the slightly less disturbing <PaginationStatusText> component:

PaginationStatusText.tsx
import { use } from 'react';
import { IMockUsersResponse } from '../api/getMockUsers';
const PaginationStatusText: React.FC<{
getDataPromise: Promise<IMockUsersResponse>;
}> = ({ getDataPromise }) => {
const response = use(getDataPromise);
return (
<span>
{response.pagination.current_page} of{' '}
{response.pagination.total_pages}
</span>
);
};
export default PaginationStatusText;

To re-iterate, neither of these components should be necessary. They are simple <button> and <span> elements that should exist on their own within the <Pagination> component and populated with the current state. Unfortunately, there’s no easy way to do that when using this combination of use and Suspense.

If you notice in <PaginationStatusText>, the data promise is being passed as a prop instead of being accessed via context. Let’s take a look at the updated <Pagination> component to understand why that’s the case:

Pagination.tsx
import { Suspense, useDeferredValue } from 'react';
import PaginationButton from './PaginationButton';
import PaginationStatusText from './PaginationStatusText';
import { getUsersContext } from '../context/UsersContext';
const Pagination: React.FC = () => {
const usersContext = getUsersContext();
const deferredDataPromise = useDeferredValue(usersContext?.dataPromise);
return (
<div>
<Suspense fallback={<button disabled={true}>Prev</button>}>
<PaginationButton type="prev" />
</Suspense>
<Suspense fallback={<span>1 of ...</span>}>
<PaginationStatusText getDataPromise={deferredDataPromise} />
</Suspense>
<Suspense fallback={<button disabled={true}>Next</button>}>
<PaginationButton type="next" />
</Suspense>
</div>
);
};
export default Pagination;

You can use the useDeferredValue hook to show stale content while new content is loading. Here I’m using the data promise as the stale content and passing the deferred value into <PaginationStatusText> as a prop. By doing that the use call within <PaginationStatusText> is seeing the previously resolved data promise and not triggering a suspended state. This means that after the initial load (where “1 of …” is visible), the status text will always display the text generated by the last successful response.

What does this project look like in SolidJS?

I want to step away from React for a minute and take a look at what this project looks like in SolidJS, a modern frontend framework that takes inspiration from the syntax and structure of React but addresses many of its flaws.

Here is the SolidJS example for reference.

Ready to see what our parent component looks like in SolidJS? Blink and you might miss it:

App.tsx
import {
createResource,
createSignal,
Match,
Suspense,
Switch,
type Component,
} from 'solid-js';
import getMockUsers from './api/getMockUsers';
import Pagination from './components/Pagination';
import UsersTable from './components/UsersTable';
import { getErrorObject } from './util/errors';
const App: Component = () => {
const [currentPage, setCurrentPage] = createSignal(1);
const [usersResponse] = createResource(currentPage, getMockUsers);
return (
<div>
<Pagination
currentPage={currentPage()}
totalPages={usersResponse.latest?.pagination.total_pages ?? 1}
disabled={usersResponse.loading}
onChangePage={setCurrentPage}
/>
<Suspense fallback={<p>Loading...</p>}>
<Switch>
<Match when={usersResponse.error}>
<p>
Error: {getErrorObject(usersResponse.error).message}
</p>
</Match>
<Match when={usersResponse()}>
<UsersTable users={usersResponse.latest?.users ?? []} />
</Match>
</Switch>
</Suspense>
</div>
);
};
export default App;

Yeah, that’s the whole thing. createResource handles the process of invoking our promise any time the currentPage value changes. The object it returns has properties for the loading and error states, which is allowing for the mix of usersResponse.loading to control the disabled Pagination state and triggering the Suspense component wrapping around UsersTable. This eliminates the awkward need to wrap everything in Suspense as was the case when creating the React 19 example.

The Pagination and UsersTable components are nearly identical to the React 18 example and only required minor changes in how props are accessed. Unlike the React 19 example, there was no need to create sub-components out of the Pagination component since the result and status of the data loading promise can easily be sent down the component hierarchy.

While I’m still new to SolidJS, I’ve been extremely impressed by it so far. I’m planning to publish another post in the near future introducing some of the core concepts for developers that are already familiar with React.

Closing notes

Using the use functionality introduced in React 19 for this example project required me to re-think how state was managed. This was especially true for the pagination component in dealing with the disabled button states and status text. While Suspense doesn’t technically break the uni-directional flow of data, it comes close to feeling like it does when compensating for fallback component rendering.

Additionally, I noticed that the component update speed once the promise had resolved is significantly faster in the React 18 example that uses useEffect versus the React 19 example with use and Suspense. To make sure I wasn’t imagining this difference, I removed the mock delay caused by setTimeout in the getMockUsers function and the delay definitely exists. I’m assuming that this caused by the mounting and un-mounting process of suspended components, but I don’t understand why it’s so severe.

At this point I’m extremely hesitant to use use/Suspense for any kind of data loading outside of the simplest needs. If all you need is a one-time data fetch when a component mounts that doesn’t need to re-fetch at any point, you’ll likely be fine with use. However, if you need to share loading or response state between components or can’t allow a general Suspense fallback to take over the entire rendering of you component, trying to push use into the situation can cause a lot of headaches.

I’m also not sure why this new functionality was simply named use. Semantically, it’s unclear and the fact that it serves a dual purpose of reading a promise and as a conditional way to read context values makes it difficult to understand exactly what it’s doing at first glance. I don’t understand why this functionality wasn’t wrapped by two separate functions named something like readPromise and readContext to provide better contextual understanding in a code base. Although use isn’t a hook, it’s common practice to prefix all hooks with use and the stock React hooks (useState, useEffect, etc.) all follow this naming convention. I’ve also never encountered a custom hook that breaks from the naming convention. Finally, if you encounter problems with use that the docs don’t clarify, it might be tough to find relevant results when using vague search terms like “react use” or “react use promise”.

If you’re building a mid to large-scale React application, you’ll likely want to use a library like TanStack Query or SWR to deal with fetching data and asynchronous state management. I’m not bothered that React doesn’t include the functionality provided by libraries like these, but I am disappointed that there isn’t better support for data fetching primitives for smaller applications that don’t need advanced features.

I was really hoping that use was going make data fetching in React easier. Judging by the official examples it certainly seemed like it would, but unfortunately the more that I attempted to do with it, the more problems I ran into. All functionality has a limit where the developer experience begins to suffer, but I’m surprised at how quickly I hit that limit with use.

Back to posts