Featured image created from photos by Dan Gold and insung yoon on Unsplash.
This is part 8 of my series on server-side rendering (SSR):
- π€·π»ββοΈ What is server-side rendering (SSR)?
- β¨ Creating A React App
- π¨ Architecting a privacy-aware render server
- π Creating An Express Server
- π₯ Our first server-side render
- π Combining React Client and Render Server for SSR
- β‘οΈ Static Router, Static Assets, Serving A Server-side Rendered Site
- [You are here] π§ Hydration and Server-side Rendering
- π¦ Debugging and fixing hydration issues
- π React Hydration Error Indicator
- π§πΎβπ¨ Render Gateway: A Multi-use Render Server
Over the course of this series on server-side rendering (SSR), we have tackled some interesting problems1.
In the last post on this topic, we finally combined our client app and server, giving an SSR result that looks and works a lot like the non-SSR version…or at least it seemed that way. As it turns out, we are missing a big piece of the SSR story and, I'm afraid to say, that once we add that in we will see that we still are not quite done. That piece is hydration.
βΉοΈCode for this series can be found at https://github.com/somewhatabstract/hello-react-world.
ππ»ββοΈ What is Hydration?
React does a lot of things to help simplify the rendering and operation of a client-side web application. In order to do that, a root component that represents our application has to be mounted into the page. This is done via the ReactDOM.render
call (React 18 and up use ReactDOM.createRoot
). It takes the component representing our application and a DOM node indicating where in the page the application will exist. In our simple web application, this looks something like this.
ReactDOM.render(<App />, document.getElementById('root'));
Without server-side rendering, the root
node that we are giving here is entirely empty. When the ReactDOM.render
call occurs, React fills that empty node with our application. When we have an SSR result, that node already has content in it. Instead of replacing that content, we want React to attach the running application to it. This is where hydration comes in. During hydration, React works quickly in a virtual DOM to match up the existing content with what the application renders, saving time from manipulating the DOM unnecessarily. It is this hydration that makes SSR worthwhile.
There are two big rules to hydrating an application in React.
- The initial render cycle of the application must result in the same markup, whether run on the client or the server.
- We must call
ReactDOM.hydrate
(React 18 and up useReactDOM.hydrateRoot
) instead ofReactDOM.render
in order to instruct React to hydrate from our SSR result.
We will get back to the first item as it is going to drive some further work once we have addressed the second; making React hydrate our SSR result.
π° Making our application hydrate
There are two ways we can make our page hydrate. We could either assume we will always SSR and therefore always call hydrate
instead of render
, or we could support both hydrate
and render
depending on how our application gets deployed. The benefit of the former is that we do not have to do any branching in our client-side code for the two modes; the benefit of the latter is that our application is flexible, allowing us to use the client-side application without demanding that we SSR, even in development.
In the end, it really is best to support both, so we are going to need to update our client application to understand the difference between the two states and act accordingly. So, how does our client application know which to do; hydrate
or render
?
There are many ways to do this. Some things that spring to mind are:
- Look at the element being used for the root of our application and if it has children,
hydrate
instead ofrender
. - Embed JavaScript in the page markup that sets a flag to specify one or the other.
- Use a function to perform the action and then use embedded JavaScript to change which operation that function performs.
I am sure you could think of some more. Since we already have to get the element in order to mount the React application, I am going to stick with option 1 for now (we can always change our minds if need be). Below I have included the index.js
of our client application before and after adding hydrate
support. In both files I have highlighted what changes. Note that we do not even have to touch our SSR implementation in order to get this working.
import React from 'react'; import ReactDOM from 'react-dom'; import './index.css'; import App from './App'; ReactDOM.render(<App />, document.getElementById('root'));
import React from 'react'; import ReactDOM from 'react-dom'; import './index.css'; import App from './App'; const mountElement = document.getElementById('root'); const reactMountFn = (mountElement.childElementCount === 0) ? ReactDOM.render : ReactDOM.hydrate; reactMountFn(<App />, mountElement);
Right, let's see what happens when we run our application with this updated code. First, in development mode, we start the client and the server2. If we have done this correctly, visiting the non-SSR route should use render
and visiting the SSR route should use hydrate
; and if we've really done things correctly, we'll never know.
Oh dear; something is not looking so great. It feels like we took a step backwards here. Last time we had a server-side rendered application that looked just like the client-only version, and now we're back to not even loading the SVG asset properly. What happened?
π± When hydration fails
If we open the console for the version that is performing hydration, we can see a big hint to our problem.
I had mentioned at the end of our last post that our SVG was getting the wrong path because we were not including our root React component with all the webpack magic, and this is the result. Our server-side rendered content has a different path than the React application wants to use when rendered on the client.
Did I say rendered? Don't I mean hydrated? Well, React is rendering the client application in a virtual DOM and comparing it to the content it is trying to hydrate in the page (our SSR result); if there is a mismatch, the rendered part wins. In the worst case, this can cause the entire application to re-render in the client in order for React to guarantee the client-side application is in the correct state.
These hydration warnings are, in my view, a big deal but are so easily overlooked. They are hidden away in the console messages of development builds – production builds do not raise them (React 18 and up will surface hydration errors in production), React just silently deals with the situation in the way it thinks best, which could be a total re-render of your application. Not only that, but they are pretty hard to debug. This one is clear, but when it comes to others, they may just say that there's a div
it did not expect; good luck finding out which div
that is. Finally, if there is more than one, you will only see the second one once you fix the first, so staying on top of these is important if you want to ensure your SSR results can be used to their full potential, avoiding those costly client-side re-renders if the hydration fails.
π€ Don't Panic
Of course, there's no need to panic; we have made incredible progress. We actually have a server-side rendering application that is attempting to hydrate. This is pretty fantastic and the only error we seem to have right now, the SVG path mismatch, is as much down to how our code is built using webpack as it is to do with React. Next time, we will dig into this hydration warning, work out how to fix our SVG path, and pave the way for a deeper dive into fixing hydration issues.
As always, thank you for your attention. Server-side rendering is a fun topic, in my opinion, but it can seem overwhelmingly complex3. I hope this series is proving useful in breaking apart that complexity and surfacing the sharp edges on which we can get easily caught. Let's smooth those edges down together.
Until then, be well! π