Modern Data-Fetching in React
See Our Public Workshops:
Data-fetching strategies have become a hot topic in React. With several approaches, tradeoffs, and considerations, one of the biggest influences in your data-fetching strategy will be your choice of architecture, which starts with the choice of SPA or framework.
Since your data-fetching strategy is so tightly coupled to architecture, this post will compare architectures at a high level. While frameworks like NextJS and Remix load data at the server, the non-framework approach to React is almost always an SPA which means data loads over the network, initiated at the client. To choose an architecture, is to choose a data-fetching strategy.
The React team also has opinions in the docs, and they're not advocating for SPA's. This won't be an anti-SPA post, but we will cover tradeoffs and try to help you understand the React team's recommendations.
Should you use a framework?
There's a recent sentiment by many in the React community that SPA's are not a great way to build apps anymore and that SSR frameworks are better. But there's also nuance in this sentiment and it's not a fair comparison because the frameworks we're talking about are essentially hybrids. They're the the best aspects of SPA's with the best aspects of MPA's (multi-page server apps), all while leaving behind the worst aspects of SPA's and MPA's.
We don't cover the hybrid nature of these frameworks in this post, but we will cover their data-fetching strategies at a high level. To start, let's take a look at the React team's recommendations for using a framework:
If you’re building a new app or a site fully with React, we recommend using a framework.
That's pretty strait forward. You can see it in the docs here.
Later in that same section, they talk about the complexities of not using a framework and they don't call out SPA's directly, but it's clear that they're referring to SPA's because they're the defacto-standard non-framework way to build React apps. They say without a framework...
As your data fetching needs get more complex, you are likely to encounter server-client network waterfalls that make your app feel very slow.
In essence, the recommendation for frameworks is really a recommendation for data-fetching on the server and to avoid the complexities of client initiated fetches, (i.e. the approach that traditional SPA's use).
Just for fun, I was curious to see if they mention "SPA" or "Single Page App" anywhere in the new docs, and I couldn't find anything. This shouldn't bother you if you still wish to build SPA's with React. Not everyone agrees with the React team's sentiment on this topic. Just know that data-fetching strategies will be coupled to this choice.
What's wrong with data-fetching from the client? We did that for 10 years!
Let's start with useEffect
since it's the "classic" hooks-way to solve data fetching with SPAs. The React team's recommendation? Don't use it. If you're doing an SPA, use third-party tools instead...
Don't use useEffect for data-fetching
The React team doesn't recommend useEffect for data-fetching anymore because of a list of drawbacks they provide in the docs. We'll go over their list in detail starting with their first:
1. "Effects don't run on the server."
For this one, keep in mind that their recommendation for frameworks is largely due to the fact that frameworks do data-fetching server-side. It's viewed that client-side data-fetching in the ways that SPA's have, are riddled with complexity vs data-fetching on the server. So if server-side data-fetching is the underlying recommendation, then it makes sense that they're recommending to not use useEffect
because it doesn't run on the server.
Does that mean useEffect
is fine if you're doing an SPA? Well, not according to the rest of the list which applies to SPA's. Here's their second reason to avoid useEffect
for data-fetching:
2. "Fetching directly in Effects makes it easy to create network waterfalls."
This could be broadened to "fetching directly in components makes it easy to create network waterfalls". A network waterfall (aka data-fetching waterfall) happens when a component renders, then fetches data, then has enough information to render other components. When those other components render, they fetch data, which leads to other components then rendering, etc. The "waterfall" refers to the fact that you get this stair-step look to your network calls which is showing you that data-fetching is happening in serial. Data fetching from within the component is prone to waterfalls because as each level of your hierarchy renders and fetches, it discovers what and how it should render next, then you start more fetching.
Visually, in the network tab it might look like this:
GET /products/1 ▓▓▓▓▓▓▓▓▓▓▓GET /brands ▓▓▓▓▓GET /products/1/related ▓▓▓▓▓▓▓▓▓▓GET /products/1/comments ▓▓▓▓▓▓▓▓▓▓▓▓▓
It's not that useEffect
is the direct culprit waterfalls, it's more like fetching from within the component is. For this reason, useEffect
, useQuery
, useSWR
, and other similar approaches that fetch from within components can create waterfalls as well. Later we'll discuss fetching from outside your component with loaders to mitigate waterfalls.
3. "Fetching directly in Effects usually means you don't preload or cache data." With some strategies you can pre-fetch data before you need it, or eliminate over-fetching with caching. However, fetching from within the component like with useEffect
means we can't fetch before you need the component since fetching is happening only as you need it. So with useEffect
, pre-fetching enhancements are not available. While you can make your own cache strategy with useEffect
, it's not recommended because of the edge cases and complexities. This is why React devs have been using Tanstack Query (formerly "React Query") to fetch instead of useEffect
directly.
But wait, since Tanstack's useQuery
is fetching from within your component, wouldn't that mean waterfalls? Perhaps, but with their amazing caching features, the waterfall problem is reduced. We'll also show you how to use Tanstack Query with loaders later to avoid waterfalls if needed.
4. "It's not very ergonomic". When we teach useEffect
for data-fetching in workshops, it gets complicated with pitfalls, race-conditions, and edge-cases. Even though it does work for data-fetching, I always say:
if you wrote a nice abstraction for
useEffect
, that had all the bells and whistles with caching, you would fall short of what already exists in open source withuseQuery
" -- a hook from Tanstack Query.
If not useEffect, then what?
After the docs list out the shortcomings of useEffect
, they have two categories of recommendations:
- Use a framework instead
- Otherwise... use third party tools like React Router loaders and Tanstack Query. (Why can't they just say "Or if you're doing an SPA" 😩)
Loaders and Caching
I don't believe Michael and Ryan ever intended to get into the "data fetching" business when they built React Router. However, it would appear that the router is a great place to handle fetching page-specific data. It knows your hierarchy of layouts and pages, it knows when you intend to change pages, and it can do data-fetching outside of the render phase which helps to avoid data-fetching waterfalls.
When you configure modern React Router, you'll use createBrowserRouter to use loaders. Its API takes nested routes as nested objects, but if you prefer nesting the routes as JSX instead, you can convert JSX to objects with createRoutesFromElements
:
const router = createBrowserRouter(createRoutesFromElements(<Route path="/" element={<RootLayout />}><Route index element={<HomePage />} /><Route path="products" element={<ProductsSubLayout />}><Route index element={<BrowseProductsPage />} /><Route path=":productId" element={<ProductPage />} /></Route></Route>,),)
We'll assume you already know how React Router evaluates a URL like site.com/products/1
to create a nested hierarchy that resembles this:
<RootLayout><ProductsSubLayout><ProductPage /></ProductsSubLayout></RootLayout>
If you need to get caught up on nested routing, here's a helpful page from their docs.
You can import a loader function from the same file we write the component. Then we can pass the loader to React Router like this:
import { loader as productLoader, ProductPage } from '~/ProductPage'const router = createBrowserRouter(createRoutesFromElements(<Route path="/" element={<RootLayout />}><Route index element={<HomePage />} /><Route path="products" element={<ProductsSubLayout />}><Route index element={<BrowseProductsPage />} /><Route path=":productId" loader={productLoader} element={<ProductPage />} /></Route></Route>,),)
Even though the loader can be anywhere, it makes sense to write it in the same file as the loader unless you need to consider lazy loading
// ProductPage.tsxexport async function loader({ params }) {const product = await fetch(`/product/${params.productId}`).then((r) => r.json())return product}export function ProductPage() {const product = useLoaderData() // returns the result of loader// ...}
React Router will call the loader function when the user transitions to site.com/products/1
. When the loader resolves data, React Router will then render component and give it the data via useLoaderData
.
This approach can be your replacement for data-fetching with useEffect
. The common pit-falls and race-conditions from useEffect
are solved with the loader. It also helps you prevent waterfalls because when two loaders are used, they are fetched in parallel. React Router wouldn't have to render one component and get its data to decide what other components get rendered. In other words, if you go to products/1
in the URL, React router already knows you want to nest these two components so it runs their loaders in parallel:
<Route path="/" element={<RootLayout />}><Route index element={<HomePage />} /><Route path="products" loader={subLayoutLoader} element={<ProductsSubLayout />}><Route index element={<BrowseProductsPage />} /><Route path=":productId" loader={productLoader} element={<ProductPage />} /></Route></Route>
What about caching and other features from Tanstack Query (useQuery
)? We can use them with the loader like this:
// Tanstack's useQuery() (data fetches in component)export function ProductPage() {const { productId } = useParams()const product = useQuery({queryKey: ['vacation', params.vacationId],queryFn: () => fetchProducts(productId),staleTime: 1000 * 30, // cache for 30 seconds})// ...}// Refactor to: Tanstack's queryClient.ensureQueryData() with// React Router loaders:export async function loader({ params }) {const product = await queryClient.ensureQueryData({queryKey: ['vacation', params.vacationId],queryFn: () => fetchProducts(params.productId),staleTime: 1000 * 30, // cache for 30 seconds})return product}export function ProductPage() {const product = useLoaderData()// ...}
queryClient.ensureQueryData
is basically the Just JavaScript™ version of useQuery
. But since it's not a hook, we can use it in the loader to get the full power of Tanstack Query + React Router loaders which avoid waterfalls.
What if we need to fetch data in a way that's not tied to the URL? Or waterfalls aren't an issue? Then just use useQuery
and you'll be fine. Many cases, waterfalls are also mitigated with good caching anyways after the the React Query cache is built.
Fetching on the server
For most of React's existence, it didn't have an ability for fetching data on the server. You can run React on the server but the component will have to re-hydrate in the client and then do data-fetching from the client. This is a big reason why frameworks like Remix and NextJS exist in the first place, to offer a data-fetching solution on the server.
The success of these frameworks has inspired new features to React as well. We now have a new "type" of component that can do data-fetching on the server called a React Server Component. RSC's come with tradeoffs of their own, but before we review those, let's see how NextJS and Remix give us data-fetching without RSC's.
Framework Data-Fetching
NextJS popularized the idea of data-fetching outside your component on the server. Their getServerSideProps
function would run on the server provide data to your page component on the server:
// NextJSexport async function getServerSideProps() {return { props: { user: 'brad' } }}export default function Page({ user }) {console.log(user) // brad}
Remix has a similar data-fetching function called loader
:
// Remixexport async function loader() {return { user: 'brad' }}export default function Page() {const { user } = useLoaderData()console.log(user) // brad}
You might notice this looks strikingly similar to how React Router looks for loaders when doing an SPA. That's because Remix is essentially a "React Router framework" that runs loaders and components on the server. Since Remix and React Router and made by the same team, they've moved many of Remix's initial features directly to React Router which has essentially reduced Remix down to almost nothing. It's just a thin wrapper over React Router with a small amount of server code. This is why the anticipated Remix v3 will now be called React Router v7. Those doing Remix v2 and those doing SPA's with React Router v6 will each be able to migrate to React Router 7. When using React Router 7, you can still do an SPA and you can still do the "remix-y" server stuff or a hybrid of both.
In both cases, NextJS and Remix gave us data-fetching with on the server with components that would re-hydrate to the client.
Where do React Server Components fit in?
There's just a few things you need to know about RSC's before we talk about the frameworks. First, RSC's look like this:
// RSC's are async React components that only run on the serverasync function MyReactServerComponent() {const products = await fetchProducts()return (<div>{products.map((product) => {return <ProductPreview key={product.id} product={product} />})}</div>)}
They only run on the server, they fetch data directly in the body of the component, and once they produce HTML their job is finished. Their HTML is sent to the client but there's no JS sent to the client for re-hydration. No re-hydration means they can't have events (JS events) or state and they don't have a concept of "re-rendering" on the client. They're called React Server Components because they only run on the server compared to those other kinds that would run on the server in SSR mode and then re-hydrate to the client.
So what do we now call those old SSR components that run on the server and re-hydrate to the client? We now call them "client components". Even though they run on the server also, we call them client-components because they can run on the client.
RSC's can be used with client components but require client "boundaries" between client and server components. RSC's also require a framework (like NextJS or Remix) because they're more of a "specification" than an actual technology that React gives us.
One of the main goals of RSC's is to limit the amount of JavaScript sent to the client for re-hydration. Also, given that they can't re-render on the client, your client-side code will also be faster with fewer things re-rendering.
As you can see, their scope is limited. They're mostly meant to be used for the static parts of your app or for data-fetching on the server to feed data into the non-RSC (client-components). They come with rules that are confusing when you're first learning them. But just keep in mind they're an option, not the "only way to do React". If you don't need them, then by all means you can still build SPA's or build server-rendered apps with Remix and NextJS that don't use them.
RCS and Frameworks
As of today, you can play with RSC's with NextJS and soon you'll see them in Remix (probably late 2024). Remember, RSC's are a framework-agnostic way to do data-fetching and NextJS is 100% embracing the React team's vision. Even though there's a way to use RSC's alongside the older getServerSideProps
API, this is mostly for legacy migration. Soon enough, I imagine NextJS developers will prefer most of their data-fetching from RSC's. Does this mean they won't have interactive apps since RSC's don't re-hydrate and can't have events? Of course not, you'll probably see this pattern where the ServerComponent
fetches data and feeds it to a ClientComponent
:
async function ServerComponent() {const products = await fetchProducts()return products.map((product) => {return <ClientComponent key={product.id} product={product} />})}
The above code is fundamentally similar to the getServerSideProps
strategy in that we had two functions, one for fetching the data, and the other was our client component that will re-hydrate. In this RSC example we have two functions again. One is a RSC for fetching data and the other is our client component that will re-hydrate.
In theory, Remix could go the same route, but I don't think they will. Loaders do really cool things in Remix that NextJS getServerSideProps
didn't do so I don't think loaders can be easily swapped out for a RSC wrapper like the example above. That being said, I'm not exactly sure how Ryan and Michael plan to provide RSC's to Remix, but Ryan does give us a big clue at the React 2024 conference.
However they decide to do it, I'm sure it will be well thought out and we'll love it!
Thanks for reading.
Instead of having comments here in our blog, we've tweeted about this post so you can comment there if you wish.
View on Twitter