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.
- Create a mock API response that accepts a
page
parameter and simulates 500ms of network latency. It must return an object satisfying the following TypeScript interface:
interface IMockUsersResponse { pagination: { current_page: number; total_pages: number; records_total: number; records_per_page: number; }; users: { id: string; email: string; name: string; }[];}
- Additional functionality like application-level caching and
stale-while-revalidate
support are not necessary for this project. - There should be three components used:
Pagination
- Contains
Prev
andNext
buttons with a “(currentPage) of (totalPages)” string between them. - The props interface should be as follows:
{currentPage: number;totalPages: number;disabled: boolean;onChangePage: (page: number) => void;}- The
Prev
andNext
buttons should be disabled when there are no more pages before or after them respectively, or if thedisabled
prop is set to true. - The
disabled
prop should be set to true while fetching data.
- Contains
UsersTable
- Takes an array of users as a single prop and displays them in a simple table with “Email” and “Name” columns.
- A parent component that contains the
Pagination
andUsersTable
components:- It is responsible for fetching the data from the API.
- The users table should be replaced with “Loading…” while results are loading.
- While the results are loading, the pagination buttons should be visible, but disabled, and the “(currentPage) of (totalPages)” text should also remain visible.
- If an error is returned from the API, it should be displayed instead of the table. The pagination should remain visible but disabled.
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:
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:
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:
function ParentComponent() { return ( <ErrorBoundary> <Suspense fallback={() => <span>Loading...</span>}> <ChildComponent /> <Suspense> </ErrorBoundary> );};
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:
function ParentComponent() { const dataPromise = useMemo( () => getDataFetchingPromise(...), [...] );
return ( <ErrorBoundary> <Suspense fallback={() => <span>Loading...</span>}> <ChildComponent dataPromise={dataPromise} /> <Suspense> </ErrorBoundary> );};
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:
- Send the promise value down through the component tree via prop drilling.
- 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:
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:
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:
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:
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:
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:
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:
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
.