DEV Community

Cover image for I shipped a dark mode hook. Then I found four bugs my own demo never hit.
Saad Ahmad
Saad Ahmad

Posted on

I shipped a dark mode hook. Then I found four bugs my own demo never hit.

Four months ago I published use-theme-mode, a small React hook for light/dark theming. It did what I wanted: managed the state, persisted the choice, read the system preference, and stayed out of my CSS.

It worked perfectly in my demo.

Then people started using it in apps that weren't mine, and I found four bugs that my demo had never once triggered. Not edge cases — the kind of thing that breaks a real page on a real Tuesday.

This is what was wrong, why my testing missed it, and what 2.0 does about it.


Bug 1: every call site had its own state

Here's the v1 hook, trimmed:

function useTheme() {
  const [theme, setTheme] = useState(getInitialTheme);

  useEffect(() => {
    document.documentElement.setAttribute("data-theme", theme);
    localStorage.setItem(THEME_KEY, theme);
  }, [theme]);

  return { theme, toggleTheme, setLight, setDark };
}
Enter fullscreen mode Exit fullscreen mode

Spot it? useState lives inside the hook. Call useTheme() in two components and you get two independent pieces of state.

function Header() {
  const { toggleTheme } = useTheme();   // state copy A
  return <button onClick={toggleTheme}>Toggle</button>;
}

function Sidebar() {
  const { theme } = useTheme();         // state copy B
  return <Icon name={theme === "dark" ? "moon" : "sun"} />;
}
Enter fullscreen mode Exit fullscreen mode

Click the button. The attribute on <html> flips, so the page turns dark. But Sidebar never re-renders, because its copy of the state never changed. The icon keeps saying "light".

My demo had one component. Of course it worked.

The fix is to move the state out of React entirely. v2 keeps a single module-level store and every hook instance subscribes to it:

const listeners = new Set<() => void>();

export function subscribe(listener: () => void) {
  listeners.add(listener);
  return () => listeners.delete(listener);
}

function commit(next: ThemeState) {
  state = next;
  applyToDom(next.resolvedTheme);
  for (const listener of listeners) listener();
}
Enter fullscreen mode Exit fullscreen mode

The hook becomes a thin subscription. Twenty components, one source of truth.

I hand-rolled the subscription rather than reaching for useSyncExternalStore, because that's React 18+ and I wanted to keep the 16.8 peer range. It's about fifteen lines.


Bug 2: system mode that stopped listening

v1 read prefers-color-scheme exactly once, inside the useState initialiser:

const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
return prefersDark ? "dark" : "light";
Enter fullscreen mode Exit fullscreen mode

That's a snapshot, not a subscription. Change your OS appearance while the tab is open — which macOS does automatically at sunset — and the page ignores you until you reload.

Worse, v1 immediately wrote that snapshot to localStorage. So the very first visit permanently pinned you to whatever your OS happened to prefer at that moment. There was no way to say "just follow the system" and mean it.

v2 treats "system" as a real, storable value that resolves at read time:

function resolve(theme: string, systemTheme: ColorScheme | undefined) {
  if (theme !== "system") return theme;
  return systemTheme;
}
Enter fullscreen mode Exit fullscreen mode

and attaches a listener so the resolution stays live:

mediaQueryList.addEventListener("change", (event) => {
  const systemTheme = event.matches ? "dark" : "light";
  commit({ ...state, systemTheme, resolvedTheme: resolve(activeTheme(), systemTheme) });
});
Enter fullscreen mode Exit fullscreen mode

This is why the hook now returns three values instead of one:

means
theme what the user chose — may be "system"
resolvedTheme what's actually on screen
systemTheme what the OS prefers, regardless of the choice

If you were branching on theme === "dark", switch to resolvedTheme or the new isDark flag.


Bug 3: localStorage can throw

This line looks harmless:

const storedTheme = localStorage.getItem(THEME_KEY);
Enter fullscreen mode Exit fullscreen mode

It isn't. Accessing localStorage throws a SecurityError in several ordinary situations:

  • Safari in private browsing, historically
  • inside a sandboxed <iframe> without allow-same-origin
  • when a user has blocked all cookies and site data
  • some enterprise-managed browser policies

And because v1 called it during the useState initialiser, the throw happened during render. Not a broken theme — a blank page.

v2 wraps every storage access:

function readStored(): string | null {
  try {
    return getStore()?.getItem(config.storageKey) ?? null;
  } catch {
    return null;
  }
}
Enter fullscreen mode Exit fullscreen mode

When storage is unavailable the theme still works for the session; it just doesn't survive a reload. Degraded, not dead.

There's a related fix: v1 applied whatever string it found in storage straight to the DOM. v2 validates it against the configured theme list first, so a stale or tampered value falls back to the default instead of putting data-theme="<script>" on your <html>.


Bug 4: the flash

The one everybody notices and nobody reports as a bug, because it feels like the cost of doing business.

In v1 the DOM update lived in an effect:

useEffect(() => {
  document.documentElement.setAttribute("data-theme", theme);
}, [theme]);
Enter fullscreen mode Exit fullscreen mode

Effects run after paint. So the sequence is: browser paints your default (light), React hydrates, effect fires, page snaps to dark. On a fast connection it's a blink. On a slow one it's a second of white.

You cannot fix this from inside React. The correct theme has to be on the element before the browser's first frame, which means a synchronous script in <head>.

v2 ships one. <ThemeScript /> renders a ~0.8 kB blocking IIFE that reads storage, resolves system preference, and writes the attribute — before anything paints.

// app/layout.tsx
import { ThemeScript } from "use-theme-mode";

export default function RootLayout({ children }) {
  return (
    <html lang="en" suppressHydrationWarning>
      <head>
        <ThemeScript />
      </head>
      <body>{children}</body>
    </html>
  );
}
Enter fullscreen mode Exit fullscreen mode

For Vite there's themeScript(), which returns the raw string so you can inline it at build time:

// vite.config.js
import { themeScript } from "use-theme-mode";
import { themeOptions } from "./src/theme-options.js";

function noFlashTheme() {
  return {
    name: "no-flash",
    transformIndexHtml: (html) =>
      html.replace("</head>", `<script>${themeScript(themeOptions)}</script></head>`),
  };
}
Enter fullscreen mode Exit fullscreen mode

The demo site uses exactly this. Both the plugin and <ThemeProvider> read the same options object, so the pre-paint pass and the React runtime can't drift apart.

Here's the test I like: block the JavaScript bundle entirely and reload. React never mounts — and the page still comes up in the right theme, because the only thing that ran was the inline script.


What else 2.0 adds

Fixing the four bugs was the reason for a rewrite. Once I was in there, three things were worth adding.

Unlimited themes

Light and dark was always an artificial limit. The hook writes a string to an attribute; it has no opinion about which strings are legal.

<ThemeProvider
  themes={["light", "dark", "sepia", "nord"]}
  colorSchemes={{ sepia: "light", nord: "dark" }}
>
  <App />
</ThemeProvider>
Enter fullscreen mode Exit fullscreen mode

colorSchemes is the part that's easy to miss. It sets the CSS color-scheme property so native UI — scrollbars, checkboxes, the text caret, date pickers — matches your custom theme. Without it, a beautiful dark "nord" page gets a bright white scrollbar.

toggleTheme() now cycles the whole list and wraps around. With the default two themes, that's still a plain toggle.

class or any attribute

v1 hardcoded data-theme. Tailwind's dark: variant wants a class, so Tailwind users had to reconfigure their build to match my library — backwards.

<ThemeProvider attribute="class">
Enter fullscreen mode Exit fullscreen mode

Tailwind v4 then needs one line of CSS:

@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
Enter fullscreen mode Exit fullscreen mode

You can also pass an array — attribute={["class", "data-theme"]} — if part of your stack wants one and part wants the other.

A configurable storage key

v1 hardcoded "app-theme". If you had another library using the same key, or wanted a per-tenant key, you were out of luck.

<ThemeProvider storageKey="my-app-theme" storage="session" />
Enter fullscreen mode Exit fullscreen mode

storage accepts "local", "session" or "none".


The size question

The whole point of this package is being small, so:

you import gzipped
useTheme only 1.84 kB
useTheme + ThemeProvider 2.64 kB
themeScript only 0.84 kB
everything 2.66 kB

Zero runtime dependencies. sideEffects: false and dual ESM/CJS output, so bundlers can drop what you don't import. TypeScript declarations ship with the package.


Migrating

Nothing was removed. useTheme, theme, toggleTheme, setLight and setDark all behave the same.

Two things to check:

1. The default storage key changed from app-theme to theme. If you have users with a saved preference, keep it:

<ThemeProvider storageKey="app-theme">
Enter fullscreen mode Exit fullscreen mode

2. theme can now be "system". If you branch on it:

- const { theme } = useTheme();
- const isDark = theme === "dark";
+ const { isDark } = useTheme();
Enter fullscreen mode Exit fullscreen mode

That's the whole migration.


What I actually learned

The bugs weren't subtle. useState inside a hook creating per-instance state is something I'd explain to someone else in an interview. Reading a media query once instead of subscribing is a well-known trap. I still shipped both.

What let them through wasn't ignorance, it was the shape of my testing. One component, one browser, one tab, storage enabled, OS theme never changing, always on localhost with a warm cache. Every one of the four bugs lives outside that box.

The demo I've built for v2 is deliberately harder on itself: four themes, several components reading the hook at once, a live readout of what the hook returns, and a second-tab test. Every bug above would be visible on that page within about ten seconds.

If you maintain a small package: the day someone opens an issue is the day it starts getting good.


Try it: use-theme-mode-demo.vercel.app — four themes, a live view of the hook's return value, and the OS-change test.

npm: npm install use-theme-mode
GitHub: saadahmad888/use-theme-mode

Issues and PRs welcome. If v2 breaks something for you, that's the useful kind of feedback — tell me.

Top comments (0)