By Paul Scanlon

React hydration error 425 "Text content does not match server-rendered HTML"

  • Gatsby
  • React

If you’re upgrading to React 18 and have run into the following error, this post should help explain what causes the error, and a couple of solutions.

Text content does not match server-rendered HTML

If you’d like to code along at home I’ve prepared a small reproduction repo with a branch for each solution.

I recently ran into this issue myself when building this demo for the following blog post: How to use Serverless Functions with SSR.

The reason was because I was using JavaScript’s Date() constructor in a few components to render a date.

The solutions discussed in this post will mainly focus on dates, but this error could occur in many different client/server scenarios.

The Problem

In my case the error occurred when using dates because of a mismatch with the date, or more specifically the time, (in seconds).

When Gatsby/React first renders a page on the server and the Date() constructor is used, the date output includes seconds. Then, shortly after the initial page load, hydration occurs. During this period the elapsed time has changed, therefore the seconds are different. This leads React to believe the “text” is different between server and client renders, and to be fair to React, it is.

The solutions I’ll be discussing will help rid you of the error by waiting for hydration to occur before attempting to initialize the date constructor, but this will likely present you with a new problem.

The New Problem

If you wait for hydration to occur before calling new Date() you’ll first display the page with an empty HTML element. This can affect Lighthouse CLS scores. In most cases this can be overcome by adding CSS to ensure the width or height of the HTML element doesn’t change. However, it will still leave you, initially with an empty element that “pings” into view after hydration.

Solutions

As mentioned, most of the following “solutions” will only prevent React from surfacing the error, but extra CSS solutions will be required to overcome the new problems these solutions create. There are also a couple of things to consider before implementing any of the solutions. I’ve done my best to explain them but feel free to comment if I’m mistaken or anything is unclear.

I’ve also included a bonus / Gatsby specific approach to handling dates. This however is dependant on where you’re souring data and if you’re able to query the date using GraphQL.

Suspense

This approach uses React’s built in Suspense method. Suspense lets components “wait” for something before rendering.

Page

// src/pages/index.js

import React, { Suspense } from 'react';

const Page = () => {
  return (
    <main>
      <h1>Page</h1>
      <time>
        <Suspense fallback={null}>{new Date().toLocaleDateString()}</Suspense>
      </time>
    </main>
  );
};

export default Page;

Hydration Safe Hook

This approach comes with a warning. The result of a hook will cause a page to re-render this could lead to performance issues because React will render the page once on initial hydration, and then again as a result of the hook. This will happen for every “page” where the hook is implemented.

Page

// src/pages/index.js

import React from 'react';

import { useHydrationSafeDate } from '../hooks/use-hydration-safe-date';

const Page = () => {
  const date = useHydrationSafeDate(new Date());

  return (
    <main>
      <h1>Page</h1>
      <time>{date}</time>
    </main>
  );
};

export default Page;

Hook

// src/hooks/use-hydration-safe-date.js

import { useState, useEffect } from 'react';

export const useHydrationSafeDate = (date) => {
  const [safeDate, setSafeDate] = useState();

  useEffect(() => {
    setSafeDate(new Date(date).toLocaleDateString());
  }, [date]);

  return safeDate;
};

Hydration Provider

This approach is a little more involved as it requires the use of React’s Context API. However, wrapping your site in a Context Provider will mean the re-render after hydration will only happen once, unlike the Hydration Safe Hook approach mentioned above.

The following demonstrates how to wrap a site with a “Provider” using Gatsby specific methods.

App Context

// src/context/app-context.js

import React, { createContext, useEffect, useState } from 'react';

export const AppContext = createContext();

export const AppProvider = ({ children }) => {
  const [isHydrated, setIsHydrated] = useState(false);

  useEffect(() => {
    setIsHydrated(true);
  }, []);

  return <AppContext.Provider value={{ hydrated: isHydrated }}>{children}</AppContext.Provider>;
};

Page

// src/pages/index.js

import React from 'react';
import { AppContext } from '../context/app-context';

const Page = () => {
  return (
    <main>
      <h1>Page</h1>
      <time>
        <AppContext.Consumer>
          {({ hydrated }) => {
            return hydrated ? new Date().toLocaleDateString() : '';
          }}
        </AppContext.Consumer>
      </time>
    </main>
  );
};

export default Page;

RootElement

// src/components/root-element.js

import React from 'react';
import { AppProvider } from '../context/app-context';

const RootElement = ({ children }) => {
  return <AppProvider>{children}</AppProvider>;
};

export default RootElement;

gatsby-browser.js

// ./gatsby-browser.js

import React from 'react';
import RootElement from './src/components/root-element';

export const wrapRootElement = ({ element }) => {
  return <RootElement>{element}</RootElement>;
};

gatsby-ssr.js

// ./gatsby-ssr.js

import React from 'react';
import RootElement from './src/components/root-element';

export const wrapRootElement = ({ element }) => {
  return <RootElement>{element}</RootElement>;
};

Format String

If your dates are sourced from local .md/.mdx files or a CMS then using GraphQL’s built-in method circumvents this problem entirely as dates are rendered at build time and can be statically returned as a string.

I have however seen folks query a date using GraphQL and then use a Date() constructor with toLocaleDateString() to format the date.

Don’t do this!

import React from 'react';
import { graphql } from 'gatsby';

const Page = ({
  data: {
    markdownRemark: {
      frontmatter: {  date }
    }
  }
}) => {
  return <time>{new Date(date).toLocaleDateString()}</time>
  );
};

export const query = graphql`
  query ($id: String!) {
    markdownRemark(id: { eq: $id }) {
      frontmatter {
        date
      }
    }
  }
`;

export default Page;

Instead, you can format the date using GraphQL.

Date locales however aren’t possible as you need to decide which date format you’d like to use ahead-of-time. There are a number of ways you can choose to format dates using GraphQL. I’ve included x3 three options in this example.

md

// content/index.md

---
date: 2022-10-20
---

# Page

Page

// src/pages/{MarkdownRemark.parent__(File)__name}.js

import React from 'react';
import { graphql } from 'gatsby';

const Page = ({
  data: {
    markdownRemark: {
      frontmatter: { title, date, dateShort, dateLong },
    },
  },
}) => {
  return (
    <main>
      <h1>{title}</h1>
      <time>{date}</time>
      <time>{dateShort}</time>
      <time>{dateLong}</time>
    </main>
  );
};

export const query = graphql`
  query ($id: String!) {
    markdownRemark(id: { eq: $id }) {
      frontmatter {
        title
        date(formatString: "DD/MM/YYYY")
        dateShort: date(formatString: "MMM DD, YYYY")
        dateLong: date(formatString: "MMMM DD, YYYY")
      }
    }
  }
`;

export default Page;

The date output for the x3 formats are as follows.

  • date = 20/10/2022
  • dateShort = Oct 20, 2022
  • dateLong = October 20, 2022

As you’ll see at the top of this page, my preference is to use the dateLong format. I’ve found this to be the one that makes most sense for me. The slight drawback of course is the month name is always in English.

Feedback Welcome!

If you know of any other solutions, or if there’s any issues with using the above methods that I’ve overlooked, please feel free to drop a comment below!

Hey!

Leave a reaction and let me know how I'm doing.

  • 0
  • 0
  • 0
  • 0
  • 0
Powered byNeon