The cache()
API is a new feature released in React 19. In this blog post, we will explore it in the Next.js App Router, and see how it can be used to reduce data coupling and preload data, optimizing performance and avoiding waterfall fetching when using React Server Components.
Table of contents
Open Table of contents
The React 19 Cache API
The React 19 cache()
API allows you to cache the result of a data fetch or computation. It’s meant to be used with React Server Components. It enables per-render caching/memoization for data fetches, primarily useful to reduce data coupling when fetching the same data across multiple components. Check out the documentation for more information!
A classic example could be something like a getUser
function:
const getUser = cache(async (userId: string) => {
return db.getUser(userId);
});
It’s likely you are calling getUser
in multiple server components. By using cache()
, you can avoid fetching the same data multiple times, and rather share the return value.
Any time you are fetching the same data in multiple components, you should consider using the cache()
API. Local fetching let’s you keep things uncoupled, and cache()
let’s you not care whether two leaf components need the same data. Without it, we would have to hoist data fetching to a higher component to avoid duplicate work. But, that would break composition and introduce coupling between components. Which is why the cache()
API is so powerful.
Another typical example in Next.js could be when creating dynamic metadata for a page, fetching data inside its generateMetadata
function. You would want to use cache()
around the data fetching function to avoid fetching the same data multiple times for that page.
However, the cache()
API can also be used to preload data with the preload pattern. Let’s see how we can use it to avoid server fetch waterfalls occurring.
The Use Case
let’s say we have a server component, here in the Next.js App Router, PostsPage
. It receives a parameter from the URL and renders an async server component, the Post
component. It also uses Suspense
to show a fallback loading state for the Post
while the data is being fetched.
export default async function PostPage({ params }: { params: Promise<{ postId: string }> }) {
const { postId } = await params;
return (
<div>
<h1>Post: {postId}</h1>
<Suspense fallback={<div>Loading post...</div>}>
<Post postId={postId} />
</Suspense>
</div>
);
}
Here is the Posts
component. It asynchronously fetches a specific post, and also renders a list of comments for that post inside another suspense boundary:
async function Post({ postId }: { postId: string }) {
const post = await getPost(postId);
return (
<div className="rounded border-2 border-blue-500 p-4">
<h2>Title: {post?.title}</h2>
Post comments:
<Suspense fallback={<div>Loading comments...</div>}>
<Comments postId={postId} />
</Suspense>
</div>
);
}
The Comments
component asynchronously fetches the comments for the post:
async function Comments({ postId }: { postId: string }) {
const comments = await getComments(postId);
return (
<div className="rounded border-2 border-slate-500 p-4">
<h2>Comments</h2>
<ul>
{comments.map(comment => {
return <li key={comment.id}>{comment.body}</li>;
})}
</ul>
</div>
);
}
Both Post
and Comments
are server components, and they are both fetching their own data asynchronously. This is nice, because each component is responsible for both its data and its UI, maintaining composition. However, the Comments
component cannot start fetching its data before Post
is done running the await to fetch its data, even though Comments
does not depend on data fetched by the Post
component. It’s blocked inside the Post
component, leading to a fetch waterfall.
Frameworks like React Router v7 and TanStack Start solve this problem with the loader pattern, ensuring all necessary data can be fetched and preloaded for the for route. However, in Next.js, we don’t have this automatic optimization.
The Solution
Since the cache()
API allows us to cache the result of a data fetch, we can use it to preload data for the Comments
component. Let’s say our data fetching functions look like this:
const getPost = async (postId: string) => {
await new Promise(resolve => setTimeout(resolve, 1000));
return [
{
body: 'This is the first post on this blog.',
id: 1,
title: 'Hello World',
},
...
].find(post => {
return post.id.toString() === postId;
});
};
const getComments = async (postId: string) => {
await new Promise(resolve => setTimeout(resolve, 1000));
return [
{
body: 'This is the first comment on this blog.',
id: 1,
postId: 1,
},
...
].filter(comment => {
return comment.postId.toString() === postId;
});
};
When running our app, we will first see the Suspense fallback for the Post
component, and then the Comments
component will start fetching its data, showing its own Suspense fallback. It will take 1 second to render the Post
component, and another 1 second to render the Comments
component.
Let’s use the cache()
API to preload the data for the Comments
component.
We can wrap getComments
in a cache()
call:
// When using cache(), the return value can be cached/memoized per render across multiple server components
const getComments = cache(async (postId: string) => {
await new Promise(resolve => setTimeout(resolve, 1000));
return [
{
body: 'This is the first comment on this blog.',
id: 1,
postId: 1,
},
...
].filter(comment => {
return comment.postId.toString() === postId;
});
});
Now, we can trigger the data fetch in a higher up component, in this case the PostsPage
. It could be any component where the necessary arguments for the data fetch are available.
export default async function PostPage({ params }: { params: Promise<{ postId: string }> }) {
const { postId } = await params;
// Prefetch the comments, but don't await the promise, so it doesn't block rendering
getComments(postId);
return (
<div>
<h1>Post: {postId}</h1>
<Suspense fallback={<div>Loading post...</div>}>
<Post postId={postId} />
</Suspense>
</div>
);
}
It’s important to not await the promise, or else it will block rendering of the PostsPage
.
Now, the Comments
component can reuse the preloaded data already triggered by the PostPage
component. In our example, since both promises are resolved after 1 second, the Comments
component will render immediately after the Post
component, skipping the waterfall!
The code example can be found on Github, and is deployed on Vercel!
Additional notes
When adding the preload pattern, it’s worth noting the hidden data coupling that can occur when refactoring. If you are using the cache()
API to preload data, and later decide to refactor the component tree and delete a deep child, you might end up with an unused preloading data fetch. This is because the data fetch is not directly coupled to the component that uses it. Worst case, you might end up with a preloading data fetch that is never used.
Therefore, it’s worth thinking about when you add the preloading pattern. Rather than adding it prematurely everywhere, use it to solve a specific performance problem. And keep it in mind when refactoring - if you are refactoring a component that uses the preloading pattern, make sure to check if the preloading is still necessary.
Another thing to note is that when using the fetch()
API in Next.js, the data is already cached/memoized per render. So, if you are using the fetch()
API to fetch the same data in multiple components or to preload data, you don’t need to wrap with cache()
. The cache()
API is primarily useful when you are fetching through a database, or running some other custom data fetching function or computation.
Key Takeaways
- The
cache()
API allows you to cache the result of a data fetch or computation, enabling per-render caching/memoization for data fetches. - The
cache()
API can be used to reduce data coupling and maintain component composition. - Any time you are fetching the same data in multiple components, you should consider using the
cache()
API, unless your data fetching is already using thefetch()
API. - The
cache()
API can also be used to preload data, allowing deepers components to reuse the preloaded data and avoid triggering a waterfall of data fetching, increasing performance. Remember not to await the preloading function. - It’s important to carefully consider where and when to implement the preloading pattern to avoid unnecessary complexity in your component hierarchy.
Conclusion
In this blog post, I’ve shown you how to use React cache()
to reduce data coupling and preload data, optimizing performance and avoiding waterfall fetching.
Thanks to Robin Wieruch and Sam Selikoff for insightful discussions on X!
I hope this post has been helpful in understanding the cache()
API and its uses. Please let me know if you have any questions or comments, and follow me on Twitter for more updates. Happy coding! 🚀