by Almina Brulić

Add a Theme Toggle to a React Router v7 Application Using localStorage

Learn how to add a light/dark theme toggle to a React Router v7 app using localStorage. This guide covers persisting user preferences, applying Tailwind CSS styles, and preventing theme flicker on page load for a smooth, consistent experience.

ReactReact Router 7
Add a Theme Toggle to a React Router v7 Application Using localStorage

Common ways to implement a theme toggle in React Router v7 apps are:

  • using cookies
  • using localStorage

Both approaches have their downsides, so you pick whichever fits your app better.

With cookies, the main issue usually comes from caching. For example, services like Cloudflare or other CDNs may cache responses and ignore updated cookie values, which can lead to your theme preference not being respected consistently across requests.

With localStorage, the most common drawback is a flicker effect. The page loads with the default theme before JavaScript runs and applies the saved preference. Another limitation is that localStorage depends on JavaScript, so if the user disables JS in their browser, the theme toggle won’t work at all.

Either way, everything can be solved with a nice workaround. In this article, I will show you how to implement it using localStorage.

1. Create generic helper functions for working with localStorage

First, let’s add a new file called local-storage.ts under the utils folder. If your app uses localStorage for more than just theme toggling, this file can serve as a central place for all your generic localStorage functions.

export const getStorageItem = (key: string) => localStorage.getItem(key)
export const setStorageItem = (key: string, value: string) => {
 try {
  localStorage.setItem(key, value)
 } catch (_e) {
  return
 }
}

export const THEME = "theme"

2. Create helper functions to get the user’s system theme, current theme, and apply the theme

You can name this file theme.ts and adjust the implementation if you like, but the following code gives you a solid starting point:

import { THEME, setStorageItem } from "./local-storage"

export function getSystemTheme(): "light" | "dark" {
 if (typeof window === "undefined") return "light"
 return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
}

export function getCurrentTheme(): "light" | "dark" {
 if (typeof document === "undefined") return "light"
 const theme = document.documentElement.getAttribute("data-theme")
 return theme === "dark" ? "dark" : "light"
}

export function applyTheme(theme: "light" | "dark") {
 document.documentElement.setAttribute("data-theme", theme)
 setStorageItem(THEME, theme)
}

3. Create a Simple Theme Toggle Component

The file theme-toggle.tsx can be used anywhere you want to render the toggle, for example in your layout.tsx route or header.tsx. When the app first loads, you will briefly see a small SunMoon icon before hydration completes. This is expected and something we accept in order to avoid UI delays or potential hydration errors that could come from some other solutions.

import { useLayoutEffect, useState } from "react"
import { Icon } from "~/ui/icon/icon"
import { applyTheme, getCurrentTheme } from "~/utils/theme"

export function ThemeToggle() {
 const [theme, setTheme] = useState<"light" | "dark" | null>(null)

 useLayoutEffect(() => {
  setTheme(getCurrentTheme())
 }, [])

 const toggle = () => {
  if (!theme) return
  const next = theme === "dark" ? "light" : "dark"
  applyTheme(next)
  setTheme(next)
 }

 if (theme === null) {
  return (
   <button type="button" aria-label="Loading theme..." onClick={toggle}>
    <Icon name="SunMoon" />
   </button>
  )
 }

 const isDarkTheme = theme === "dark"
 return (
  <div className="relative">
   <button type="button" aria-label={`Switch to ${isDarkTheme ? "light" : "dark"} mode`} onClick={toggle}>
    <Icon name={isDarkTheme ? "Moon" : "Sun"} />
   </button>
  </div>
 )
}

4. Apply styles based on the theme mode

Using Tailwind v4, you can define your theme styles in tailwind.css. Inside the @layer base, add your light mode styles under :root, and your dark mode styles under [data-theme="dark"]. The file might look something like this:

@import "tailwindcss";

@layer base {
 :root {
  --color-background: #fafafa;
  --color-border: #e5e7eb;
  --color-text-normal: #2d3748;
  --color-text-hover: #1a202c;
  // ...
 }

 [data-theme="dark"] {
  --color-background: #0f0f0f;
  --color-border: #1b1f2e;
  --color-text-normal: #e6edf3;
  --color-text-hover: #cfcfcf;
  // ... 
 }
}

5. Avoid flicker and hydration mismatch

In the root.tsx, to prevent the UI from flashing the wrong theme on first load, we set the data-theme attribute before React hydrates. The small inline <script> in the <head> checks localStorage for a saved theme or falls back to the system preference and applies it immediately. This way, the correct theme is already on the <html> element when the page renders, avoiding flicker and hydration mismatches. Inside React, we then use useLayoutEffect to read and set the stored theme synchronously before the browser paints, so the UI never shows the wrong theme.

import { THEME, getStorageItem, setStorageItem } from "./utils/local-storage"
import { getSystemTheme } from "./utils/theme"
// other imports 

// rest of the code: loaders, actions, default App component, etc.

export const Layout = ({ children }: { children: React.ReactNode }) => {
 const [theme, setTheme] = useState(() => {
  if (typeof window === "undefined" || !window.localStorage) {
   return "dark"
  }
  return getStorageItem(THEME) || getSystemTheme()
 })

 useLayoutEffect(() => {
  const storedTheme = getStorageItem(THEME)
  if (storedTheme) {
   setTheme(storedTheme)
  }
 }, [])

 useEffect(() => {
  setStorageItem(THEME, theme)
 }, [theme])

 return (
  <html className="overflow-y-auto overflow-x-hidden" data-theme={theme}>
   <head>
    <script
     // biome-ignore lint/security/noDangerouslySetInnerHtml: Sets correct theme on initial load
     dangerouslySetInnerHTML={{
      __html: `
       (function () {
        try {
         var theme = localStorage.getItem("theme");
         if (!theme) {
          theme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
         }
         document.documentElement.setAttribute("data-theme", theme);
        } catch (_) {}
       })();
      `,
     }}
    />
  // rest of meta tags and links 
   </head>
   <body className="h-full w-full bg-[var(--color-background)]">
    {children}
    // rest of body content
   </body>
  </html>
 )
}
...

That’s it! Go ahead and try it out yourself and feel free to leave comments, claps, questions, or suggestions.

Stay tuned for new blog posts. Happy coding!

Follow Me for More Updates