I was recently working on a React component in a shared component library. The component library up until recently had only been used in client-side rendered apps (think Create React App). However, once it started being used in server-sie rendered apps (think Next.js), I started getting the React server hydration mismatch error. For prop errors, the warning looks something like:
Warning: Prop `className` did not match. Server: "positive" Client: "zero"
or for text content:
Warning: Text content did not match. Server: "0" Client: "5"
Unlike most React warnings, this warning doesn't link to a doc explaining the warning in greater details with some suggested solutions (like for instance the Invalid Hook Call Warning). There are docs on the hydrate()
function that does the client-side hydrating on a server-rendered app, but we'd have to know to find it.
Let's walk through what's happening in my component. Here is a representation of the code:
const Incrementer = () => {
// initialize the state from a value in `localStorage`
// if it exists
const [value, setValue] = useState(() => {
// using lazy state initialization so that we only
// read from `localStorage` the very first render
let initValue
// test for the presence of `window` because it
// won't exist in Node for server-side rendering.
// we'll only get the `localStorage` value in
// the browser
if (typeof window === 'object') {
initValue = window.localStorage.getItem('value')
}
return initValue ? parseInt(initValue, 10) : 0
})
let className = 'zero'
if (value < 0) {
className = 'negative'
} else if (value > 0) {
className = 'positive'
}
return (
<div className={className}>
<button onClick={() => setValue((curValue) => curValue - 1)}>-</button>
<span>{value}</span>
<button onClick={() => setValue((curValue) => curValue + 1)}>+</button>
</div>
)
}
FYI: This is not the actual code in the component library. The actual code is a
useMedia()
Hook for tracking the match state of a media query in Javascript. That code is quite involved, so I've simplified it with this example.
The component is a very standard minimal example that's used to show interactivity in a React component. The only difference is that it optionally retrieves its initial value from localStorage
using lazy state initialization. If you don't know what lazy state initialization is or when you should use it, check out a previous post of mine called Four characters can optimize your React component.
Because window
doesn't exist in the Node environment during server-side rendering, the component first has to check if window
exists before accessing localStorage
. Therefore, when the component is rendered server-side, value
will always be 0
, the default value (className
will also be "zero"
). And when the component renders on the client-side, window
will exist, and it can read from localStorage
to get the initial value.
The fact that React components can render server-side is pretty cool. But unfortunately this code doesn't quite work as expected. The server-side render actually works fine with the default 0
value. Also, if the component was only rendered client-side, the component renders fine as well (reading from localStorage
if the stored value exists). But it's when the component is hydrating on the client from server rendered HTML that we have a problem. React expects the initial render on the client to match what was rendered on the server. To get a better understanding of hydration, read Josh W. Comeau's blog post, The Perils of Rehydration.
But if the component gets a value from localStorage
(let's say "15"
) with the initialization of state, both value
(15
) and className
("positive"
) will be different than the default values (0
and "zero"
) rendered on the server. And when what is hydrated on the client differs from what was server-rendered, we get the server hydration error.
Warning: Text content did not match. Server: "0" Client: "15"
And the real error isn't actually the warning. The real problem is that our UI is "stale." React doesn't update the UI to match the props that were rendered differently by the client during hydration. Instead it keeps the attributes that were already in the markup from the server-side rendering. React does update the text content, however. So in our example, 15
will be rendered in the <span>
. But because it does not update props, the className
will still be "zero"
(instead of "positive"
).
Typical fix
The typical fix for this is to move all of the localStorage
stuff into the useEffect()
Hook. That's what it's for; a place that we can safely access the DOM (including window
) that has no impact on server-side rendering. The fixed code would now look something like:
const Incrementer = () => {
// always initialize the state to `0`
const [value, setValue] = useState(0)
let className = 'zero'
if (value < 0) {
className = 'negative'
} else if (value > 0) {
className = 'positive'
}
useEffect(() => {
// once we've hydrated on the client w/ the initial
// render, check to see if we have a value stored
// in `localStorage`. if so, update `value`. this
// will result in a second render.
// (no need to check for existence of `window` cuz
// it's guaranteed to be there)
const storedValue = window.localStorage.getItem('value')
if (storedValue) {
setValue(parseInt(storedValue, 10))
}
}, [])
return (
<div className={className}>
<button onClick={() => setValue((curValue) => curValue - 1)}>-</button>
<span>{value}</span>
<button onClick={() => setValue((curValue) => curValue + 1)}>+</button>
</div>
)
}
NOTE: Apps using a CSS-in-JS library (such as emotion) also run into this warning with mismatched
className
props. But the issue isn't that the component is accessing APIs only available in the browser. The problem is that the server-side render doesn't contain the finalclassName
that is generated on the client. Most CSS-in-JS libraries have a guide for solving this problem (for example emotion's Server-side rendering guide).
Our warning is gone and our UI is correct!
Now instead of conditionally initializing the value
state based on whether or not window
exists and whether or not there's a value stored in localStorage
, we just always initialize the value
state to the default 0
. The server-side render remains the same, rendering 0
and "zero"
. And the client-side hydration on the initial render also renders 0
and "zero"
.
But then after the initial render, the useEffect()
Hook kicks in and runs the effect. That is when we read localStorage
if it exists and update the value
state. Calling setValue
causes the component to re-render with the new value. So now value
will be 15
and className
will be "positive"
. Our UI now looks good. 👍🏾
The drawback with this fix is that it requires 2-pass rendering on the client. The first pass at hydration matches the server-side rendering, and the second pass after useEffect()
is based on the localStorage
information. There's no real way to avoid this dual rendering with sever-side rendering (yet).
However, when this component is rendered in client-side only apps, it still does the 2-pass rendering. There's no hydration from server rendering happening, so there's no need for the initial render to use the default values. Ideally, as an optimization, the initial render will read from localStorage
(which it can do because we're only rendering in the browser) and render the very first time with the correct data. This is basically what the initial code was doing. That original code works great for client-side only apps, but as we've seen breaks with server-side rendered apps.
Remember, this component can exist in either client-side only apps or server-side rendered apps. And since it was still be used throughout lots of client-side only apps, I didn't want to add that extra render to all of these apps.
Ideal fix
The ideal fix would be for us to determine from React that the initial render of the component is actually the hydration render. Something that would tell us that this rendering is a result of the app calling ReactDOM.hydrate()
versus ReactDOM.render()
. That way if the component is rendering on the server or hydrating from the server, we'll use the default values and do 2-pass rendering. Otherwise, we'll know we're only client-side rendering and can optimize to single-pass rendering.
In all my googling, I did find Github links to some React internals that I could import to know if the component was in the hydration phase. But I avoid importing React internals at all cost. The Github link was for React 16, so no doubt it changed with React 17. I didn't even bother to look because I knew that wasn't the route I wanted to go.
Workaround fix
So since I couldn't realistically find out from React if it was hydrating, I did the next best thing: used a global variable 😭.
// helper to read the `localStorage` value and parse to an
// integer, if it exists
const getLocalStorageValue = () => {
const storedValue = window.localStorage.getItem('value')
return storedValue ? parseInt(storedValue, 10) : undefined
}
const Incrementer = () => {
// initialize the state from a value in `localStorage`
// if it exists and not hydrating
const [value, setValue] = useState(() => {
// using lazy state initialization so that we only
// read from `localStorage` the very first render
let initValue
// in addition to testing for `window` we also check
// if the app wasn't server-side rendered. when it
// wasn't, it's safe to get the `localStorage` value
// early here in state initialization
if (typeof window === 'object' && !window.__WAS_SSR) {
initValue = getLocalStorageValue()
}
return initValue || 0
})
let className = 'zero'
if (value < 0) {
className = 'negative'
} else if (value > 0) {
className = 'positive'
}
useEffect(() => {
// if the app wasn't server-side rendered, `value`
// already is the `localStorage` value, so getting and
// setting it again won't cause a re-render. however,
// if the app was server-side rendered, `value` is the
// default so we need to now get the value from
// `localStorage` and set it
const storedValue = getLocalStorageValue()
if (storedValue) {
setValue(storedValue)
}
}, [])
return (
<div className={className}>
<button onClick={() => setValue((curValue) => curValue - 1)}>-</button>
<span>{value}</span>
<button onClick={() => setValue((curValue) => curValue + 1)}>+</button>
</div>
)
}
The code is basically a hybrid of the original solution (optimized, but broken) and the second solution (sometimes inefficient, but always accurate). Now we have a solution that is always accurate, and efficient for client-side only renders as well.
The state initialization is almost the same as the original code except it also checks against the window.__WAS_SSR
property ("was server-side rendered") set by the app. If the app said that it wasn't server-side rendered or didn't set the global property at all, then the component will do the optimized single-pass render by reading the localStorage
in the state initialization. The assumption is that if the app says that it wasn't server-side rendered, there's no hydrating going to happen. The useEffect()
Hook still runs, but doesn't cause a second render because the value
will be the same.
However, if the window.__WAS_SSR
property is set to true
by the app, then on the client the first render is hydration. So then the state initialization will return the default value to match the server-side render. It's when the useEffect()
Hook executes that the real value is set. This is the 2-pass solution.
Although the component doesn't know if it's being rendered in a client-side only app or a server-side rendered app, the app itself does know how it's being rendered. So a server-side rendered app can set the window
value in module scope in the top-most component.
// in App.js (or equivalent)
import React from 'react'
// other imports as needed
if (typeof window === 'object') {
// mark that the app was server-side rendered
window.__WAS_SSR = true
}
const App = () => {
// render the app
}
export default App
For my Next.js projects, I set this in the custom
App
(pages/_app.js
).
I use the window
object because it will only exist in the browser, which is where hydration will happen.
One thing to note is that this solution biases towards client-side only apps. If the window.__WAS_SSR
property is not set, it'll do the optimized single-pass render by default. This means that any server-side rendered app will be broken by default without the window
property. I could've named the property something like window.__NO_SSR
so that the component by default would do 2-pass rendering. And only if the property is set will it do the optimized 1-pass rendering.
I chose my approach because the vast majority of the apps consuming the React component library are still client-side only and I didn't want to have to go update them all. It was much easier to update the 1 or 2 new server-side rendered app.
Theming fix
As I mentioned earlier, this Incrementer
component is a simplification of my actual code, a useMedia()
custom Hook. Although I wasn't thrilled with the global variable approach, I felt a bit validated because MUI (my go-to React UI library) has its own useMediaQuery()
Hook that takes a similar global approach.
The Hook has the same problem of being used in both client-side only and server-side rendered apps, without knowing which environment it's in. But instead of resorting to a global variable like I did, they made use of their theming engine which is a much more robust global UI data store. So when the app sets up the primary and secondary colors, it can also set the noSsr
property for MUI to only do the single-pass rendering.
If my component library provides theming in the future, I'll definitely switch to this approach!
Phew! That was a lot of information. 😅 The code itself isn't really all that complicated. It's just that React hydration and its ramifications aren't really well-known. So we really have to understand the problem first in order for the solution to make sense.
I still want to see what I can do about the 2-pass solution for server-side rendered apps. The result is that the user sees the component with the default value values, and some time later with the up-to-date values. Depending on what's being rendered and the user's internet connection, the switch over can be slow and jarring (or fast and hardly noticeable). I'm not sure what the solution will be, but I will share another post if/when I have something.
Keep learning my friends. 🤓