19 June, 20233 minute read

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:

useLocalStorage.ts
Click to copy
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.

An example of a hydration error caused by our buggy useLocalStorage hook
An example of a hydration error caused by our buggy useLocalStorage hook

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:

useLocalStorage.ts
Click to copy
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.

Don't want to miss out on new posts?

Join 100+ fellow engineers who subscribe for software insights, technical deep-dives, and valuable advice.

Get in touch 👋

If you're working on an innovative web or AI software product, then I'd love to hear about it. If we both see value in working together, we can move forward. And if not—we both had a nice chat and have a new connection.
Send me an email at hello@sophiabits.com