Search code examples
javascriptreactjsnext.jsserver-side-rendering

Is it possible to suppress hydration warnings for all nested children of a component in NextJS?


Note: I am using NextJS 14 but believe this is also a problem in NextJS 13

I have build a ThemeToggle component that uses next-themes. If I build out my own implementation of next-themes using React Context or any other state management solution I'll run into this same problem.

Here's my component:

"use client";

import { useTheme } from "next-themes";
import { FaSun, FaMoon } from "react-icons/fa";

export default function ThemeToggle() {
  const { theme, setTheme } = useTheme();

  return (
    <button onClick={() => setTheme(theme === "dark" ? "light" : "dark")}>
      {theme === "dark" ? <FaSun /> : <FaMoon />}
    </button>
  );
}

This is a client component because the useTheme hook is stateful and the server doesn't have the context of the theme state until it is rendered on the client.

This component works really well except for this annoying hydration warning that I get:

app-index.js:31 Warning: Prop d did not match. Server: ...

This is clearly referring to the d prop of the underlying svg in my react-icons icons FaSun and FaMoon.

It makes sense that there is a mismatch here between client and server and I understand why this is occurring because according to the server, theme is undefined initially until the client renders which then defines what the theme is using localStorage.

I found that the recommended way to avoid this hydration error is to first check if the component is mounted before rendering my FaSun or FaMoon component. I accomplished this by rendering a fallback icon in place like this:

"use client";

import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
import { FaSun, FaMoon } from "react-icons/fa";
import { TbFidgetSpinner } from "react-icons/tb";

export default function ThemeToggle() {
  const { theme, setTheme } = useTheme();
  const [isMounted, setIsMounted] = useState(false);

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

  return (
    <button onClick={() => setTheme(theme === "dark" ? "light" : "dark")}>
      {!isMounted ? (
        <TbFidgetSpinner className="animate-spin" />
      ) : theme === "dark" ? (
        <FaSun />
      ) : (
        <FaMoon />
      )}
    </button>
  );
}

The Problem with this Approach

While this works and I no longer get the error, my user experience has deteriorated. Now whenever I change pages in my NextJS application is flashes to that fallback component.

The user experience was much better when I just had that server-client hydration mismatch, and the hydration mismatch isn't really a big deal considering the context is just that the server potentially won't render an icon correctly.

Is there a way to suppress this hydration warning?

Things I've tried

  • Use next/dynamic with the ssr: false flag

I've tried using next/dynamic to render my ThemeToggle component on its parent like this:

import dynamic from 'next/dynamic'
const ThemeToggle = dynamic(() => import("./ThemeToggle"), { ssr: false })

This doesn't solve my UX problem with the loading, it actually makes it worse because that just hides the component on page change. If I use a Suspense in there it works the same as my isMounted implementation which is just as bad if not just a worse developer experience.

  • use the suppressHydrationWarning prop

I tried to use the suppressHydrationWarning prop on the button and also the FaSun and FaMoon components like this:

<button
  onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
  suppressHydrationWarning
>
  {theme === "dark" ?
    <FaSun suppressHydrationWarning /> :
    <FaMoon suppressHydrationWarning />
  }
</button>

Unfortunately the suppressHydrationWarning prop only applies directly the the element we're suppressing, not the underlying children. And given I can't update my FaSun and FaMoon components directly - since they come from a 3rd party library - this doesn't work.

Current Work Around

I haven't tried this yet, but as of right now I believe the only way for me to get around this is to build my own sun and moon icons and apply the suppressHydrationWarning prop to the underlying affected elements directly. This isn't ideal but it will get the job done.

Conclusion

Is there something I am missing with the go to solution around these kinds of warnings?

I don't want to turn of all hydration warnings because they are valuable, but I also don't want the ones that I expect to be lingering there. I also don't want to compromise the user experience.

Is there something that might exist like a SuppressHydrationWarningBoundary that will cover all underlying hydration warnings in a component that would work something like this?

<button onClick={() => setTheme(theme === "dark" ? "light" : "dark")}>
  <SuppressHydrationWarningBoundary>
    {theme === "dark" ? <FaSun /> : <FaMoon />}
  </SuppressHydrationWarningBoundary>
</button>

Solution

  • The UX issue here was caused by something beyond the scope of this question.

    I was using a tags instead of NextJS's Link component and that was causing the terrible flashes when changing pages.