Featured Image by Wolfgang Hasselmann on Unsplash
This is part 10 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
- 💧 Hydration and Server-side Rendering
- 🦟 Debugging and fixing hydration issues
- [You are here] 🛑 React Hydration Error Indicator
- 🧑🏾🎨 Render Gateway: A Multi-use Render Server
My series of blog posts about React server-side rendering and hydration is almost at a close.
Before we get to the end, I wanted to take a moment to share a little utility I created that has helped us at Khan Academy to catch and fix hydration errors during development. Normally, these are only reported to the console and as such are not very visible to folks when working on their frontend features. So, I added a development-time trap to catch and surface them in the user interface – raising their visibility and improving our chances of catching them before they reach production. It is not foolproof, but it does help; especially since until React 18 there is no built-in way to detect hydration issues for production; after React 18 a similar approach works in production as these errors do get reported, though with less information than the development build.
There are two parts to the hydration indicator; the trap that catches the error and the UX component that surfaces it within the app. The trap patches the console.error
method and looks for specific message patterns. We only apply the trap during hydration; we have a call to apply the trap and another to remove it. We also have a simple method to format the error message in a similar manner to the console so that our indicator component will have some nice text to show to developers. The component is then rendered based off the presence of that trapped error. In our development server, we show a status icon for the hydration state and then have a popover to show more error info if the icon is clicked. In the example given here, I just render the captured error message, though I am sure you could take this and expand it into something much more helpful to your use case (for example, there is an graphical indicator of the hydration state in our development UX that has a tooltip with more information on the actual error).
/** * This little function does the `%s` string substitution that * `console.error|log|warn` methods do. That way we can show a meaningful * message in our alert. */ const formatLikeLog = (message, args) => { let i = 0; return message.replace( /%s|%d|%f|%@/g, (match, idx) => args[i++], ); }; let removeTrapFn = null; let error = null; export const removeTrap = () => removeTrapFn?.(); export const getHydrationError = () => error; /** * Patch a console with new error method to capture rehydration errors. * * By default, this patches the global console and with a callback to show an * alert dialog in the browser. */ export const applyTrap = ( elementId, consoleToPatch = console, ): ( () => void ) => { if (removeTrapFn != null) { throw new Error("Trap already applied."); } const originalError = consoleToPatch.error; // Record the removal fn. removeTrapFn = () => { consoleToPatch.error = originalError; removeTrapFn = null; }; // Reset the hydration error. error = null; // Patch the console error method so we can intercept rehydration errors. consoleToPatch.error = (message, ...args) => { // First, log to the console like usual. originalError(message, ...args); /** * Detecting hydration errors is much nicer in more recent versions of * React, thank goodness. While the error message used to not * indicate a hydration-specific issue, it now does contain text that * alludes to hydration problems. * * See ./node_modules/react-dom/cjs/react-dom.development.js for * all the various warnings - snippers of relevant hydration warnings * are used here to detect them. */ const possibleWarnings = [ "Expected server HTML ", "Text content did not match.", "Prop `.*` did not match.", "Extra attributes from the server", "Did not expect server HTML to contain", ]; const mapper = (w) => `(?:${w})`; const warningsGrouped = possibleWarnings.map(mapper).join("|"); const warningRegex = new RegExp(`Warning: ${warningsGrouped}`); if (warningRegex.test(message)) { // Looks like a hydration error. const formattedMessage = formatLikeLog(message, args); error = { message: formattedMessage, elementId, }; removeTrap(); } }; return removeTrap; };
import {getHydrationError} from "./trap.js"; export const HydrationErrorIndicator = () => { if (process.env.NODE_ENV === "production") { return null; } const [showError, setShowError] = useState(false); useEffect( () => { setShowError(true); }, [showError], ); const error = getHydrationError(); // Very simple rendering of the error. return showError && error && <div>{error.message}</div>; };
The trap is applied around the React.hydrate
call. Here's a simple example though you may want to modify this to only load the trap in non-production code, for example.
const removeTrap = applyTrap("my-app-id"); try { React.hydrate(<MyApp />, document.getElementById("my-app-id")); } finally { removeTrap?.(); }
I hope others find this useful. It has certainly helped us to gain confidence in our server-side rendering and hydration, giving us an early signal to investigate potential issues. If you make use of this or have other ways you have improved your server-side rendering reliability, please do share in the comments.