Server Side State management in NextJS: a deep dive into React Cache
Posted on Oct 18th, 2024 in NextJS
In previous posts, I explored how Server Components in NextJS improve performance by offloading more rendering to the server. Managing server-side state differs from client-side, as it’s static and immutable during a single render. NextJS provides tools like cache, unstable_cache, and patched fetch to manage this state efficiently, reducing prop drilling and simplifying code. These tools offer caching across requests, but require a shift from traditional client-side approaches for better performance
My last two posts, Architecting NextJS Applications for Better Performance and NextJS foot guns: Over-reliance on Client Side State, talked about how Server Components and rendering as much as you can on the server to improve Core Web Vitals. However, State management is difficult. This is why there are a plethora of Client side state management libraries for React. So how do we manage state with out React’s traditional approaches such as context?
Summary
- Server Side state is tricky to manage in NextJS because its so different from traditional Client side state
- Because its so different, a lot of Next developers will fall back to client solutions.
- Next and React exposes three functions to manage caching and state management on the server:
cache
,unstable_cache
and patchedfetch
. - Since react cache is unique per request, and its mutable, we can take advantage of
cache
to more effectively manage our server side state - This only works on server components, but can lead to simpler code by avoiding prop drilling.
Server Side State
Server Side state is fundamentally different from client side state. Where Client side changes based on a user’s action on a web page, Server state is simple rendering state and is immutable in a given render path. Changing Server state can’t trigger re-renders, where that is the entire point of the Client state. You’ll notice that this is in the name of the library: React
, because it reacts to a user’s action.
How Server State works

This diagram shows how Server state works. Red and black arrows represent things that only happens on server, where as blue arrows represents things that happen on the client and server. You’ll notice that the red and black arrows only point in one direction: down. On the server, you can not tell Page.tsx
to re-render because some state changed in the Featured Product List.
Intuitively, this makes sense. If you continually trigger re-render components in a server environment, how does React know when the page is actually ready to be served to a client? The answer is it can’t so the approach React follows is to only allow a single render loop. That means that the state has to be set as close to the root, and then the state becomes more read-only, otherwise the state will change further down the tree you go.
One technique that I see happen a lot is data fetching mostly, or exclusively, happens in side of page.tsx
and the state is passed into a client state management library such as RTK or Apollo Client. Then inside of child components, using a hook to read from that hydrated state. This works in both Server and Client contexts, so the server response does include the rendered HTML that you would expect. However this also means that client needs this code as well and will re-execute this code on the client when the page is hydrated.
How to manage Server Side State
Since Server state is unidirectional, managing server state in NextJS is pretty different than how you would manage client state. The differences stem from a few principals:
- This state is Uni-directional
- The state should be mostly static (IE does not change based on user’s actions)
- The State is based on Request or path information
NextJS exposes two different caching types that impacts how state is managed server side: Request Memoization and Data Cache. Next exposes three functions to expose to manipulate these caches, cache
, unstable_cache
and patched fetch
.
The first cache type, Request Memoization, is manipulated by the cache
and fetch
functions. This cache is a request specific cache, meaning it only lives for the duration of a single request. This extends to ISR and Static page builds too. The cache only impacts a single page. This is useful for storing data that changes often, or is different for different pages.
The second cache type, the Data Cache, is persistent across page requests. It is manipulated by unstable_cache
and by patched fetch
given certain parameters. This is useful for maintaining some data that is the same across many different pages or data that does not change often.
Using cache
to manage Server Side State
If you read the NextJS or React docs, you’ll see a lot of examples like this for using the cache
function:
import {cache} from 'react';
import calculateMetrics from 'lib/metrics';
const getMetrics = cache(calculateMetrics);
function Chart({data}) {
const report = getMetrics(data);
// ...
}
If you call getMetrics
with the same inputs, instead of calling calculateMetrics
over and over again, React will cache the first call and keep returning that result. Basically, the functions parameters becomes the cache key for you. This is useful so long as you can always guarantee the params will be the same and easy to find access to. But what happens when we have a few different components that need to make some fetch based on the page params? Now you have to pass some state down your component tree, and suddenly this becomes more difficult. Take this example:
// file: /app/products/[productID]/getProductDetails
import {cache} from "react";
export const getProductDetails = cache((productID: string) => executeSql("SELECT * from products where id=?", productID);
// file: /app/products/[productID]/page.tsx
import ProductDetails from "./ProductDetails";
import getProductDetails from "./getProductDetails";
type PageParams = {
params: {
productID: string;
}
}
export default function Page(props: PageParams) {
const { productTitle } = getProductDetails(props.params.productID);
return <div>
<h1>{productTitle}</h1>
<ProductDetails productID={props.params.productID} />
}
// file /app/products/[productID]/ProductDetails.tsx
import ProductImages from "./ProductImages";
export default function ProductDetails(props: { productID: string}) {
const { description, price } = getProductDetails(props.productID);
return <div>
<ProductImages productID={props.productID} />
<div>
<p>{description}</p>
<button onPress={() => addToCart(props.productID)}>{price}</button>
</div>
</div>
}
// file /app/products/[productID]/ProductImages.tsx
export default function ProductDetails(props: { productID: string}) {
const { images } = getProductDetails(props.productID);
return <div>
{images.map(src => <img src={src} />}
</div>
}
In this contrived example, you can already see the problem here. While getProductDetails
does cache on the first call, it still requires that you pass down some information however far down the tree in order to respect that cache. In a sufficiently large next application, this becomes extremely difficult to manage, causing the number of props a child needs to accept to increase exponentially in order to make decisions on how to render.
How ever, NextJS nor React mention that the cache is actually mutable.
Managing state by mutating the cache
Since the Cache is mutable, we can actually use it outside of the context of passing in parameters to get cached data. Lets modify getProductDetails
to illustrate this:
import { cache } from "react";
const ProductCache = cache((): {productDetails?: ProductDetails} => {productDetails: undefined});
export const getProductDetails = async (productID?: string) => {
if (productID) {
const productDetails = await executeSql("SELECT * from products where id=?", productID);
ProductCache().productDetails = productDetails;
return productDetails;
}
const productDetails = ProductCache().productDetails;
if (productDetails === undefined) {
throw new Error("Can't get product details unless it has been hydrated first!")
}
return productDetails;
}
In this example, we first create a seemingly useless function into cache
. What this is doing is creating a function with no parameters and binding into the cache. This means that this function will always return the same cached value, no matter what. Next we modify our getProductDetails
to not use cache
function directly, but instead make the same fetch as before if a productID is passed in, and then call the ProductCache
function and assign the results of the sql query to the return object of the cache. This allows us to keep the result of the first call without needing to be always aware of some piece of context of the request. Here is what our example product page will look like now:
// file: /app/products/[productID]/page.tsx
import ProductDetails from "./ProductDetails";
import getProductDetails from "./getProductDetails";
type PageParams = {
params: {
productID: string;
}
}
export default function Page(props: PageParams) {
const { productTitle } = await getProductDetails(props.params.productID);
return <div>
<h1>{productTitle}</h1>
<ProductDetails />
}
// file /app/products/[productID]/ProductDetails.tsx
import ProductImages from "./ProductImages";
export default function ProductDetails() {
const { description, price } = await getProductDetails();
return <div>
<ProductImages />
<div>
<p>{description}</p>
<button onPress={() => addToCart(props.productID)}>{price}</button>
</div>
</div>
}
// file /app/products/[productID]/ProductImages.tsx
export default function ProductDetails(props: { productID: string}) {
const { images } = await getProductDetails();
return <div>
{images.map(src => <img src={src} />}
</div>
}
Notice how now we avoid prop drilling, and have some shared state between all of the components that does not rely on passing in some props all the way down the tree. In fact, this almost acts like a client side context.
Important notes
First, while this pattern may look and feel like a client side hook and context, its important to note that this is completely different. In fact trying to use this pattern in Client components will fail since the cache
function can not be shared to the client. Because you can’t share this with the client, I do not recommend prefixing this functions with use
like you would a hook.
Secondly, because server state does not cause a re-render, you have to be careful on accidentally overriding the cache if you perform a write operation by mistake. This can lead to the top half of your tree rendering different state vs the bottom half.
Finally, the cache does not work like you would expect a typical Context to work. You can not wrap certain parts of your render tree in a different cache “provider” to modify the state for just that subtree. If you do something like this, it will impact all calls to the cache after you modified that state.