How to avoid hydration errors in Gatsby with React.lazy

Andreas Löw
How to avoid hydration errors in Gatsby with React.lazy

Motivation

This whole page is created using Gatsby, a static page renderer that creates really fast web pages. What I love about it, is that I can directly integrate React components to make the page interactive.

The problem with this page is, that as it grows, the size of the generated javascript files also grows, increasing the page load times.

I am using Prism for highlighting code blocks. So the Prism library and all its dependencies quickly became part of the main application bundle - even on pages that did not use highlighting at all.

Code splitting in React

To prevent this kind of bloat, React supports code splitting. That's quite easy to apply by importing your component using React.lazy:

const PrismHighlighter = React.lazy(() => import("./prism-highlighter"));

When you want to use the component you just have to put it inside a <Suspense> block. It renders the fallback component until the lazy loaded component is available:

<Suspense fallback={<div>Loading</div>}>
    <PrismHighlighter code={codeString}>
        ...
    </PrismHighlighter>
</Suspense>

Sounds easy. But there's one disadvantage: Google will not be happy about this because it creates Cumulative Layout Shift (CLS). That means that before loading the highlighter component Loading is displayed, which is replaced with the code block which might need more space on your webpage. This pushes all your blog post text down and moves parts of your page. Google considers this as a bad practice and this will hurt your search engine rankings.

To prevent this, the fallback component has to match the same size as the lazy loaded component. In case of the syntax highlighter, you can easily use an un-highlighted version of the same code block.

This also has SEO advantages since the code is visible in the pre-rendered version of the page. It's just swapped with the colored version later.

<Suspense fallback={<pre>{codeString}</pre>}>
    <PrismHighlighter code={codeString}>
        ...
    </PrismHighlighter>
</Suspense>

Gatsby is unhappy about React.lazy...

Everything seems to work as long as you are not in Gatsby's SSR mode - that means you are building your page for release.

After building the page you might see something very strange:

  • The content appears as expected
  • The content disappears and is replaced with the placeholder you specified in the Suspense
  • The content appears again

This is because during server side rendering, the content already gets baked into the html pages. This is why it's there directly after loading the page. After that, hydration kicks in and replaces the content with the fallback component. Finally, after loading the real component, the content appears again.

This is obviously not what you want.

And even worse: When opening the page, the console now is full of hydration errors:

  • 418: Hydration failed because the initial UI does not match what was rendered on the server.
  • 422: There was an error while hydrating this Suspense boundary. Switched to client rendering.

This is because the static HTML page already contains the content, but React looks for the fallback when hydrating the Suspense block.

The work-around proposed by the Gatsby developers is to prevent rendering of the component during the build phase for the static pages:

const isSSR = typeof window === "undefined";

return {!isSSR && <Suspense>...</Suspense>}

With this, you don't get any errors from Gatsby - but the whole block is completely removed from the pre-rendered HTML.

Problem solved? Not at all. This solution is bad because it causes Cumulative Layout Shift. And if the content is relevant it might not be visible to search engines. This solution hurts SEO.

My solution

My current solution for this is a component I called SuspenseHelper:

SuspenseHelper.tsx
import { ReactNode, useEffect, useState, Suspense } from 'react';

type Props = {
    fallback?: ReactNode,
    children: ReactNode
}

export const SuspenseHelper: React.FC<Props> = ({fallback, children}) => {

    const [isMounted, setMounted] = useState<boolean>(false);

    useEffect( () => {
        if(!isMounted)
        {
            setMounted(true);
        }
    })

    return (
        <Suspense fallback={fallback}>
            {!isMounted ? fallback : children}
        </Suspense>
    )
};

It displays the fallback component and replaces it with the lazy loaded component after a successful load. It also compiles the fallback component into the static HTML pages. With this, the hydration of the page is stable. It also solves the issues with SEO and CLS.

To use the component, simply replace Suspense with SuspenseHelper:

<SuspenseHelper fallback={<pre>{codeString}</pre>}>
    <PrismHighlighter code={codeString}>
        ...
    </PrismHighlighter>
</SuspenseHelper>