← Writings

I shipped a hydration bug on my own portfolio. Here's the fix.

reactnext.jsssrdesign engineering

I got a portfolio review last week and the very first thing the reviewer flagged was a console error visible on every page of my own site. The kind of error that doesn't break anything visually, but if you open DevTools out of habit, you see six React hydration warnings.

I'd like to say I caught this myself. I didn't.

What was happening

My theme toggle reads the saved preference from localStorage to decide which icon to render (Sun or Moon) and which aria-label to use. The provider state was initialized like this:

const [preference, setPreference] = useState<Theme>(getStoredPreference);

function getStoredPreference() {
  if (typeof window === "undefined") return "system";
  return localStorage.getItem("theme-preference") ?? "system";
}

Looks fine. It even has the typeof window guard. But the bug is sitting right there in plain sight.

On the server, getStoredPreference() returns "system", which falls back to light. So the server renders a Moon icon and aria-label="Switch to dark mode".

On the client, the same function reads localStorage. If a returning visitor had dark saved, the client renders a Sun icon and aria-label="Switch to light mode".

React reconciles, sees the mismatch, and gives up:

A tree hydrated but some attributes of the server rendered HTML didn't match the client properties. This won't be patched up.

Why the pre-paint script doesn't save you

I already had an inline <script> in <head> that sets the dark class on <html> before React hydrates. That's what stops the dark-mode flash. But it doesn't help the toggle's icon, that's React-rendered JSX, and React validates against the JSX it produced on the server.

Two systems of truth: the inline script is honest about the user's preference; the SSR React tree is not.

The fix

Render a stable placeholder on first paint, then resolve to the real icon after mount. Two changes, both small:

ThemeToggle:

const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);

return (
  <button aria-label={mounted ? `Switch to ${theme === "light" ? "dark" : "light"} mode` : "Toggle theme"}>
    {mounted ? (theme === "light" ? <Moon /> : <Sun />) : <span className="block h-[14px] w-[14px]" />}
  </button>
);

ThemeProvider:

// Initial state matches the server every time
const [preference, setPreference] = useState<ThemePreference>("system");
const [systemTheme, setSystemTheme] = useState<Theme>("light");

useEffect(() => {
  setPreference(getStoredPreference());
  setSystemTheme(getSystemTheme());
}, []);

The pre-paint script keeps the visual right. React state catches up after hydration. No mismatch.

What I learned

Two things, mostly about myself:

One. I'd built the thing, looked at the page, and never opened the console. The page rendered fine, so I assumed the page was fine. A design engineer who doesn't check the console is half a design engineer.

Two. The bug had been there for months. Nobody told me. Probably nobody noticed. But "nobody noticed" is a poor quality bar, the kind that, in aggregate, separates a portfolio that feels right from one that doesn't. The hidden errors compound the same way the visible polish does.

I'm now treating the console as a surface, the same way I treat the homepage. If it's noisy, the site is noisy. If it's empty, the site is honest.

(There's also a small easter egg in there now. Open DevTools and say hi.)