The Perils of Hydration

An Eye-Opening Realization about React

Introduction

I ran into the strangest issue recently. Everything was groovy in development, but in production, the bottom of my blog was doing something… unintended:

A rendering issue causes a newsletter signup form to be squashed and overlapping the article / footer contentA hot mess of UI soup

A bit of digging into the Elements tab in the devtools revealed the culprit… My React component was rendering in the wrong spot!

How could this be? Had I discovered a bug in React? I checked the React Devtools "⚛️ Components" tab, and it told a different story, one in which everything was fine, and the pieces were all where they were supposed to be. What a liar!

It turns out, I had a fundamental misunderstanding about how React works in a server-side-rendering context. And I think many React devs share this misunderstanding! And it can have some pretty serious ramifications.

Link to this heading
Some problematic code

Here's an example of code that can cause the kind of rendering issue shown above. Can you spot the problem?

jsx

For a long time, I would have believed that this code was A-OK. Right up until my blog started impersonating a Picasso painting.

This tutorial will peek behind the curtain to help us understand how server-side rendering works. We'll see why the logic shown here can be problematic, and how a different approach can accomplish the same goal.

Link to this heading
Server-side rendering 101

To understand the problem, we need to first dig a little into how frameworks like Gatsby and Next.js differ from traditional client-side apps built with React.

When you use React with something like create-react-app, all of the rendering happens in the browser. It doesn't matter how large your application is, the browser still receives an initial HTML document that looks something like this:

html

The page is fundamentally empty, but it includes a couple JS scripts. Once the browser downloads and parses those scripts, React will build up a picture of what the page should look like, and inject a bunch of DOM nodes to make it so. This is known as client-side rendering, since all the rendering happens on the client (the user's browser).

All of that stuff takes time, and while the browser and React are working their magic, the user is staring at a blank white screen. Not the best experience.

Smart people realized that if we could do that rendering on the server, we could send the user a fully-formed HTML document. That way, they'd have something to look at while the browser downloads, parses, and executes the JS. This is known as server-side rendering (SSR).

Server-side rendering can be a performance win, but the thing is, that work still needs to be done on-demand. When you request your-website.com, React has to transform your React components into HTML, and you'll still be staring at a blank screen while you wait for it. It's just that the work is being done on the server, not on the user's computer.

The galaxy-brain realization is that huge chunks of many websites and apps are static, and they can be built at compile-time. We can generate the initial HTML way ahead of time, on our development machines, and distribute it immediately when a user requests it. Our React apps can load as quickly as a vanilla HTML site!

This is exactly what Gatsby does (along with Next.js, in certain configurations). When you run yarn build, it generates 1 HTML document for every route on your site. Every side page, every blog post, every store item — an HTML file is created for each of them, ready to be served up immediately.

Link to this heading
Code on the client

The apps we build nowadays are interactive and dynamic—users are accustomed to experiences that can't be accomplished with HTML and CSS alone! So we still need to run client-side JS.

The client-side JS includes the same React code used to generate it at compile-time. It runs on the user's device, and builds up a picture of what the world should look like. It then compares it to the HTML built into the document. This is a process known as hydration.

Critically, hydration is not the same thing as a render. In a typical render, when props or state change, React is prepared to reconcile any differences and update the DOM. In a hydration, React assumes that the DOM won't change. It's just trying to adopt the existing DOM.

Link to this heading
Dynamic sections

This takes us back to our code snippet. As a reminder:

jsx

This component is designed to have three possible outcomes:

  • If the user is logged in, render the <AuthenticatedNav> component
  • If the user is NOT logged in, render the <UnauthenticatedNav> component.
  • If we don't know if the user is logged in or not, render nothing.

Link to this heading
Schrodinger's user

In a macabre thought experiment, Austrian physicist Erwin Schrödinger describes a situation: a cat is placed in a box with a toxin that has a 50% chance of being released within an hour. After an hour, there is an equal probability that the cat is alive or dead. But until you open the box and find out, the cat can be thought of as both alive and dead.

In our webapp, we face a similar predicament; for the first few moments that a user is on our site, we don't know whether they are logged in or not.

This is because the HTML file is built at compile-time. Every single user gets an identical copy of that HTML, regardless of whether they're logged in or not. Once the JS bundle is parsed and executed, we can update the UI to reflect the user's state, but there is a significant gap of time before that happens. Remember, the whole point of SSG is to give the user something to look at while we download, parse, and hydrate the app, which can be a lengthy process on slow networks/devices.

Many webapps choose to show the "logged out" state by default, and this leads to a flicker you've probably run into before:

The Guardian news website shows a 'Sign in' link, before replacing it with 'Your account'.
Airbnb makes the same mistake, defaulting to a logged-out navigation bar.

I took the liberty of building a mini Gatsby app that reproduces this issue:

On 3G speeds, the wrong state is shown for quite a while!

Link to this heading
A noble but flawed attempt

In the shared code snippet, we attempt to solve for this problem in the first few lines:

jsx

The idea here is sound: Our initial compile-time build happens in Node.js, a server runtime. We can detect whether or not we're rendering on the server by checking to see if window exists. If it doesn't, we can abort the render early.

The problem is that in doing so, we're breaking the rules. 😬

Link to this heading
Hydration ≠ render

When a React app hydrates, it assumes that the DOM structure will match.

When the React app runs on the client for the first time, it builds up a mental picture of what the DOM should look like, by mounting all of your components. Then it squints at the DOM nodes already on the page, and tries to fit the two together. It's not playing the “spot-the-differences” game it does during a typical update, it's just trying to snap the two together, so that future updates will be handled correctly.

By rendering something different depending on whether we're within the server-side render or not, we're hacking the system. We're rendering one thing on the server, but then telling React to expect something else on the client:

html
html

Somewhat remarkably, React can still handle this situation sometimes. You may have done this yourself, and gotten away with it. But you're playing with fire. The hydration process is optimized to be ⚡️ fast ⚡️, not to catch and fix mismatches.

Link to this heading
About Gatsby in particular

The React team knows that hydration mismatches can lead to funky issues, and they've made sure to highlight mismatches with a console message:

A dev-tools console error message: “Warning: Expected server HTML to contain a matching <div> in <nav>.”

Unfortunately, Gatsby only uses the server-side rendering APIs when building for production. And because React warnings in general only fire in development, it means that these warnings are never shown when building with Gatsby 😱

This is a trade-off. By opting out of server-side-rendering in dev, Gatsby is optimizing for a short feedback loop. Being able to quickly see the changes you make is so, so important. Gatsby prioritizes speed over accuracy.

This is kind of a significant problem, though; folks in an open issue are advocating for a change, and we may start seeing hydration warnings.

Until then, though, it is especially important to be mindful of this when developing with Gatsby!

To avoid issues, we need to ensure that the hydrated app matches the original HTML. How do we manage "dynamic" data then?

Here's what the solution looks like:

js

We initialize a piece of state, hasMounted, to false. While it's false, we don't bother rendering the "real" content.

Inside the useEffect call, we immediately trigger a re-render, setting hasMounted to true. When this value is true, the "real" content gets rendered.

The difference from our earlier solution: useEffect only fires after the component has mounted. When the React app adopts the DOM during hydration, useEffect hasn't been called yet, and so we're meeting React's expectation:

html
html

Immediately after this comparison, we trigger a re-render, and this allows React to do a proper reconciliation. It'll notice that there's some new content to render here—either an authenticated menu, or a login link—and update the DOM accordingly.

Here's what that solution looks like, in our reproduction case:

A blank spot is shown during the initial render. After mount, a re-render updates it with the true state.

Link to this heading
Two-pass rendering

Have you ever noticed that the expiration date on cereal clearly wasn't printed at the same time as the rest of the box? It's stamped on, after the fact:

The top of a Lucky Charms box, showing how the expiration date is stamped imprecisely onto a large blue rectangleThe top of a Cheerios box, matching the Lucky Charms box with a blue rectangle and stamped expiration date

There's a logic to this: cereal-box printing is a two-step process. First, all of the "universal" stuff is printed: the logo, the cartoon leprechaun, the enlarged-to-show-texture photograph, the random pics of smart-watches. Because these things are static, they can be mass-produced, printed millions at a time, months in advance.

They can't do that with expiration dates, though. At that moment in time, the manufacturers have no idea what the expiration date should be; the cereal that will fill those boxes probably doesn't even exist yet! So they print an empty blue rectangle instead. Much later, after cereal has been produced and injected into the box, they can stamp on a white expiration date and pack it up for shipment.

Two-pass rendering is the same idea. The first pass, at compile-time, produces all of the static non-personal content, and leaves holes where the dynamic content will go. Then, after the React app has mounted on the user's device, a second pass stamps in all the dynamic bits that depend on client state.

Link to this heading
Performance implications

The downside to two-pass rendering is that it can delay time-to-interactive. Forcing a render right after mount is generally frowned upon.

That said, for most applications, this shouldn't make a big difference. Usually the amount of dynamic content is relatively small, and can be quickly reconciled. If huge chunks of your app are dynamic, you'll miss out on many of the benefits of pre-rendering, but this is unavoidable; dynamic sections can't be produced ahead of time by definition.

As always, it's best to do some experimentation of your own if you have concerns around performance.

On this blog, I wound up needing to defer a handful of rendering decisions to the second pass, and I was sick of writing the same logic over and over again. I created a <ClientOnly> component to abstract it:

jsx

Then you can wrap it around whichever elements you want to defer:

jsx

We could also use a custom hook:

js
jsx

With this trick up my sleeve, I was able to solve my rendering issue. The day was saved!

While neat, the abstractions aren't the most important part of this tutorial. The critical bit is the mental model.

When working in Gatsby/Next apps, I've found it really helpful to think in terms of a two-pass render. The first pass happens at compile-time, wayyy ahead of time, and sets the foundation for the page, filling in everything that is universal for all users. Then, much later, a second-pass render will fill in the stateful bits that vary from person to person.

I've been building with React for over 8 years now, and I've built up a ton of useful mental models for understanding how React works, and how to use it effectively. And, honestly, I friggin’ love React. I've tried just about every front-end framework under the sun, and nothing makes me feel as productive as React.

For the past two years, I've been assembling all of that knowledge into an online learning experience. I call it “The Joy of React”.

Visit the “Joy of React” homepage

The #1 goal of this course is to help build your intuition for React, so that you get stuck less often on weird quirks like hydration bugs, and start to enjoy developing with it! I want you to love React as much as I do!

You can learn more about the course, and discover the joy of building with React:

Last Updated

September 19th, 2023

Hits

A front-end web development newsletter that sparks joy

My goal with this blog is to create helpful content for front-end web devs, and my newsletter is no different! I'll let you know when I publish new content, and I'll even share exclusive newsletter-only content now and then.

No spam, unsubscribe at any time.



If you're a human, please ignore this field.