Fixing React hydration errors with double-rendering
Moving from purely client rendered React apps to using server rendering is great for performance, user experience, and SEO but can be a tricky transition for learners to make. Server rendering imposes additional constraints on developers, and if these constraints are broken your app will likely not function correctly.
The most obvious constraint is that browser APIs such as localStorage
are unavailable within a server context. Attempting to use an API like localStorage
without first checking whether it’s available will result in a ReferenceError
being thrown at render time. Fortunately, this issue is easy to work around with a typeof
check and if you forget to do this the failure is noisy and therefore easy to identify.
The second and much harder to debug constraint is that the first render of your application inside the browser must match exactly what the server rendered. This sounds easy on paper, but in practice it’s quite easy to make silly mistakes that result in hydration errors and broken apps.
Consider the following buggy React hook:
function useLocalStorage( key: string, defaultValue: string | null = null,) { const [state, setState] = useState( typeof localStorage === 'undefined' ? defaultValue : localStorage.getItem(key), ); function setValue(newValue: string) { localStorage.setItem(key, newValue); setState(newValue); } return [state, setValue];}
This hook provides a useState
-like wrapper around a key inside localStorage
. The implementation guards the use of localStorage
with a typeof
check which prevents the hook from exploding on the server, but this typeof
check actually introduces a subtle issue which can cause hydration errors.
The issue is that on the server, our typeof localStorage === 'undefined'
check evaluates to true
while in the browser it evaluates to false
. This means that the server will always render the page using the default value passed to the hook, and the browser will render the page with whatever is in local storage. If the value in local storage doesn’t match our default value, then the browser and server are going to render different results.
We can fix this error by using a pattern I call “double-rendering.” Instead of using a typeof
check to guard our use of the browser API, we instead unconditionally use the default value on our initial render and then use the browser API to initialize our state inside an effect. You can see this approach in action below:
function useLocalStorage( key: string, defaultValue: string | null = null,) { const [state, setState] = useState(defaultValue); useEffect(() => setState(localStorage.getItem(key)), [key]); function setValue(newValue: string) { localStorage.setItem(key, newValue); setState(newValue); } return [state, setValue];}
In the adjusted version of the hook the browser always performs the first render using defaultValue
, which matches the rendering behavior of the server. This fixes the hydration error, and the general pattern here is transferrable to many other similar scenarios.
FAQs
Do I always need to double-render when using browser APIs?
No. Hydration errors only occur when the markup rendered by the client differs from the markup rendered on the server. If our useLocalStorage
example only uses the stored value in an event handler or other callback and not for any rendering-related purpose then it won’t cause a hydration error and we’re fine to use the original version of the hook.
Sometimes it can be nice to add an optional object to your hooks which opts consumers in or out from double-rendering. This gives users the ability to indicate when double-rendering is necessary.
What are the performance implications of this pattern?
We’re effectively burning a render here in order to work around the hydration error. The cost of making an extra render will depend on your application and how high up the double-render is occurring. In general I’d recommend being cautious of this pattern—oftentimes there’s a better solution available.
Also be mindful of libraries which use this pattern under the hood. MUI’s useMediaQuery
hook can take an optional noSsr
option and when it’s set to false
(the default) the hook will perform a double-render to avoid hydration errors caused by the server not knowing the client’s viewport dimensions.
What other solutions are there for hydration errors?
Leveraging the browser platform is always a good idea. While the useMediaQuery
hook mentioned previously is convenient and allows writing all of your code in Javascript, you’re often better off just using real CSS media queries. Rendering responsive HTML and CSS completely avoids hydration errors while also being better for performance because there’s no need to double-render.