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>
);
}
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?
next/dynamic
with the ssr: false
flagI'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.
suppressHydrationWarning
propI 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.
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.
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>
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.