AdSense Leaderboard

Google AdSense Placeholder

HEADER SLOT

Build a Theme Toggle Hook in React: Complete Guide (2026)

Last updated:
Build a Theme Toggle Hook in React: Complete Guide (2026)

Master theme switching in React with a custom useTheme hook. Learn localStorage persistence, TypeScript safety, and context patterns with production-ready code examples.

# Build a Theme Toggle Hook in React: Complete Guide (2026)

Dark mode isn't just a trendy feature anymore—it's what users expect. Whether you're building a content platform, a SaaS dashboard, or an e-commerce site, theme switching has become table stakes. But implementing it properly requires more than just toggling a CSS class. You need state management, persistence, system preference detection, and clean reusability across components.

In this guide, you'll learn how to build a production-ready theme toggle system using React's custom hooks pattern. We'll cover everything from basic implementation to advanced patterns with TypeScript, localStorage persistence, and context API integration.

# Table of Contents

  1. Why Build a Custom Theme Hook?
  2. Basic Theme Toggle Implementation
  3. Adding LocalStorage Persistence
  4. System Preference Detection
  5. TypeScript Implementation
  6. Context Pattern for Global Theme
  7. Practical Application: Dashboard Theme Switcher
  8. FAQ

# Why Build a Custom Theme Hook?

Before jumping into code, let's understand why a custom hook is the right approach for theme management.

When you build theme switching directly in components, you end up repeating the same logic everywhere: reading from localStorage, updating the DOM, syncing across tabs, and handling system preferences. This duplication creates maintenance headaches and makes it easy to introduce bugs.

A custom hook solves this by centralizing all theme logic in one place. Components simply call useTheme() and get back everything they need—no knowledge of implementation details required. This matches React's composition model perfectly.

Custom hooks also make testing easier. Instead of testing theme logic in every component, you test the hook once and trust that any component using it inherits that reliability.

# Basic Theme Toggle Implementation

Let's start with the simplest version—a hook that toggles between light and dark themes.

# JavaScript Version

javascript
import { useState } from 'react';

export function useTheme() {
  const [theme, setTheme] = useState('light');
  
  const toggleTheme = () => {
    setTheme(currentTheme => currentTheme === 'light' ? 'dark' : 'light');
  };
  
  return { theme, toggleTheme };
}

This hook uses useState to track the current theme and provides a toggleTheme function that switches between light and dark. The updater function form (currentTheme => ...) ensures we always work with the latest state value, which matters when dealing with rapid toggles.

# TypeScript Version

typescript
import { useState } from 'react';

type Theme = 'light' | 'dark';

export function useTheme() {
  const [theme, setTheme] = useState<Theme>('light');
  
  const toggleTheme = () => {
    setTheme(currentTheme => currentTheme === 'light' ? 'dark' : 'light');
  };
  
  return { theme, toggleTheme };
}

The TypeScript version adds a Theme type that restricts values to only 'light' or 'dark'. This prevents typos and catches errors at compile time rather than runtime.

# Using the Hook

Here's how you'd use this hook in a component:

javascript
function App() {
  const { theme, toggleTheme } = useTheme();
  
  return (
    <div className={theme}>
      <header>
        <h1>My App</h1>
        <button onClick={toggleTheme}>
          Switch to {theme === 'light' ? 'dark' : 'light'} mode
        </button>
      </header>
      <main>
        <p>Current theme: {theme}</p>
      </main>
    </div>
  );
}

The component destructures theme and toggleTheme from the hook, then uses them to render UI and handle user interactions. The className={theme} applies theme-specific styles through CSS.

# Adding LocalStorage Persistence

The basic version resets to light mode on every page refresh. Users expect their theme choice to persist. Let's add localStorage support.

# JavaScript Version with Persistence

javascript
import { useState, useEffect } from 'react';

export function useTheme() {
  // Read from localStorage on mount, default to 'light'
  const [theme, setTheme] = useState(() => {
    const savedTheme = localStorage.getItem('app-theme');
    return savedTheme || 'light';
  });
  
  // Sync to localStorage whenever theme changes
  useEffect(() => {
    localStorage.setItem('app-theme', theme);
    document.documentElement.setAttribute('data-theme', theme);
  }, [theme]);
  
  const toggleTheme = () => {
    setTheme(currentTheme => currentTheme === 'light' ? 'dark' : 'light');
  };
  
  return { theme, toggleTheme };
}

Two key changes here:

  1. Lazy initialization: The function passed to useState runs only once on mount, reading from localStorage without blocking the initial render.

  2. Sync effect: Whenever theme changes, we save to localStorage and update the data-theme attribute on the document root. This attribute makes CSS styling cleaner—you can use selectors like [data-theme="dark"] .button instead of managing multiple class names.

# TypeScript Version with Persistence

typescript
import { useState, useEffect } from 'react';

type Theme = 'light' | 'dark';

export function useTheme() {
  const [theme, setTheme] = useState<Theme>(() => {
    const savedTheme = localStorage.getItem('app-theme') as Theme | null;
    return savedTheme || 'light';
  });
  
  useEffect(() => {
    localStorage.setItem('app-theme', theme);
    document.documentElement.setAttribute('data-theme', theme);
  }, [theme]);
  
  const toggleTheme = () => {
    setTheme(currentTheme => currentTheme === 'light' ? 'dark' : 'light');
  };
  
  return { theme, toggleTheme };
}

The TypeScript version adds a type assertion for savedTheme, indicating it could be a Theme type or null if nothing was saved yet.

# System Preference Detection

Modern browsers expose user system preferences through window.matchMedia. Let's detect whether the user prefers dark mode and use that as the default.

# JavaScript Version with System Preference

javascript
import { useState, useEffect } from 'react';

function getSystemTheme() {
  if (typeof window === 'undefined') return 'light';
  
  const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
  return isDark ? 'dark' : 'light';
}

export function useTheme() {
  const [theme, setTheme] = useState(() => {
    const savedTheme = localStorage.getItem('app-theme');
    return savedTheme || getSystemTheme();
  });
  
  // Listen for system theme changes
  useEffect(() => {
    const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
    
    const handleChange = (event) => {
      // Only update if user hasn't manually set a preference
      const savedTheme = localStorage.getItem('app-theme');
      if (!savedTheme) {
        setTheme(event.matches ? 'dark' : 'light');
      }
    };
    
    mediaQuery.addEventListener('change', handleChange);
    return () => mediaQuery.removeEventListener('change', handleChange);
  }, []);
  
  useEffect(() => {
    localStorage.setItem('app-theme', theme);
    document.documentElement.setAttribute('data-theme', theme);
  }, [theme]);
  
  const toggleTheme = () => {
    setTheme(currentTheme => currentTheme === 'light' ? 'dark' : 'light');
  };
  
  const resetTheme = () => {
    localStorage.removeItem('app-theme');
    setTheme(getSystemTheme());
  };
  
  return { theme, toggleTheme, resetTheme };
}

This version adds two features:

  1. System detection: getSystemTheme() checks the user's OS-level preference and uses it as the fallback when no saved preference exists.

  2. Dynamic updates: The effect listens for changes to system preferences. If the user switches their OS from light to dark mode, the app responds automatically—but only if they haven't manually chosen a theme.

  3. Reset function: resetTheme() clears the saved preference and reverts to system settings.

# TypeScript Version with System Preference

typescript
import { useState, useEffect } from 'react';

type Theme = 'light' | 'dark';

function getSystemTheme(): Theme {
  if (typeof window === 'undefined') return 'light';
  
  const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
  return isDark ? 'dark' : 'light';
}

export function useTheme() {
  const [theme, setTheme] = useState<Theme>(() => {
    const savedTheme = localStorage.getItem('app-theme') as Theme | null;
    return savedTheme || getSystemTheme();
  });
  
  useEffect(() => {
    const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
    
    const handleChange = (event: MediaQueryListEvent) => {
      const savedTheme = localStorage.getItem('app-theme');
      if (!savedTheme) {
        setTheme(event.matches ? 'dark' : 'light');
      }
    };
    
    mediaQuery.addEventListener('change', handleChange);
    return () => mediaQuery.removeEventListener('change', handleChange);
  }, []);
  
  useEffect(() => {
    localStorage.setItem('app-theme', theme);
    document.documentElement.setAttribute('data-theme', theme);
  }, [theme]);
  
  const toggleTheme = () => {
    setTheme(currentTheme => currentTheme === 'light' ? 'dark' : 'light');
  };
  
  const resetTheme = () => {
    localStorage.removeItem('app-theme');
    setTheme(getSystemTheme());
  };
  
  return { theme, toggleTheme, resetTheme };
}

# TypeScript Implementation

For production apps, you'll often want to support more than two themes. Here's how to build a flexible, type-safe version.

# TypeScript Version with Multiple Themes

typescript
import { useState, useEffect } from 'react';

type Theme = 'light' | 'dark' | 'auto';

interface UseThemeOptions {
  defaultTheme?: Theme;
  storageKey?: string;
}

interface UseThemeReturn {
  theme: Theme;
  resolvedTheme: 'light' | 'dark';
  setTheme: (theme: Theme) => void;
  toggleTheme: () => void;
}

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

export function useTheme(options: UseThemeOptions = {}): UseThemeReturn {
  const {
    defaultTheme = 'auto',
    storageKey = 'app-theme'
  } = options;
  
  const [theme, setThemeState] = useState<Theme>(() => {
    const savedTheme = localStorage.getItem(storageKey) as Theme | null;
    return savedTheme || defaultTheme;
  });
  
  // Resolve 'auto' to actual theme based on system preference
  const resolvedTheme = theme === 'auto' ? getSystemTheme() : theme;
  
  useEffect(() => {
    const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
    
    const handleChange = () => {
      // Force re-render when system theme changes and user is on 'auto'
      if (theme === 'auto') {
        setThemeState('auto');
      }
    };
    
    mediaQuery.addEventListener('change', handleChange);
    return () => mediaQuery.removeEventListener('change', handleChange);
  }, [theme]);
  
  useEffect(() => {
    localStorage.setItem(storageKey, theme);
    document.documentElement.setAttribute('data-theme', resolvedTheme);
  }, [theme, resolvedTheme, storageKey]);
  
  const setTheme = (newTheme: Theme) => {
    setThemeState(newTheme);
  };
  
  const toggleTheme = () => {
    setThemeState(currentTheme => {
      if (currentTheme === 'light') return 'dark';
      if (currentTheme === 'dark') return 'auto';
      return 'light';
    });
  };
  
  return { theme, resolvedTheme, setTheme, toggleTheme };
}

This advanced version introduces several improvements:

  1. Auto mode: Users can choose 'auto' to follow system preferences automatically
  2. Configurable options: Pass in custom storage keys and default themes
  3. Resolved theme: Separate the user's choice (theme) from what's actually applied (resolvedTheme)
  4. Type safety: Full TypeScript types ensure you can't pass invalid themes

# Context Pattern for Global Theme

When multiple components need theme access, prop drilling becomes tedious. React Context solves this by making the theme available to any component in the tree.

# TypeScript Version with Context

typescript
import { createContext, useContext, ReactNode } from 'react';
import { useTheme } from './useTheme';

type Theme = 'light' | 'dark' | 'auto';

interface ThemeContextValue {
  theme: Theme;
  resolvedTheme: 'light' | 'dark';
  setTheme: (theme: Theme) => void;
  toggleTheme: () => void;
}

const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);

interface ThemeProviderProps {
  children: ReactNode;
  defaultTheme?: Theme;
  storageKey?: string;
}

export function ThemeProvider({ 
  children, 
  defaultTheme, 
  storageKey 
}: ThemeProviderProps) {
  const themeValue = useTheme({ defaultTheme, storageKey });
  
  return (
    <ThemeContext.Provider value={themeValue}>
      {children}
    </ThemeContext.Provider>
  );
}

export function useThemeContext() {
  const context = useContext(ThemeContext);
  
  if (context === undefined) {
    throw new Error('useThemeContext must be used within ThemeProvider');
  }
  
  return context;
}

# JavaScript Version with Context

javascript
import { createContext, useContext } from 'react';
import { useTheme } from './useTheme';

const ThemeContext = createContext(undefined);

export function ThemeProvider({ children, defaultTheme, storageKey }) {
  const themeValue = useTheme({ defaultTheme, storageKey });
  
  return (
    <ThemeContext.Provider value={themeValue}>
      {children}
    </ThemeContext.Provider>
  );
}

export function useThemeContext() {
  const context = useContext(ThemeContext);
  
  if (context === undefined) {
    throw new Error('useThemeContext must be used within ThemeProvider');
  }
  
  return context;
}

# Using the Context Pattern

Wrap your app with the provider:

typescript
import { ThemeProvider } from './ThemeContext';

function App() {
  return (
    <ThemeProvider defaultTheme="auto" storageKey="my-app-theme">
      <Header />
      <MainContent />
      <Footer />
    </ThemeProvider>
  );
}

Then use the hook in any component:

typescript
import { useThemeContext } from './ThemeContext';

function ThemeToggleButton() {
  const { theme, toggleTheme } = useThemeContext();
  
  return (
    <button onClick={toggleTheme}>
      Current: {theme}
    </button>
  );
}

The context pattern eliminates prop drilling. Components deep in the tree can access theme state without intermediate components knowing about it.

# Practical Application: Dashboard Theme Switcher

Let's build a complete dashboard theme switcher that combines everything we've learned. This example shows how a real SaaS product might implement theming.

# TypeScript Implementation

typescript
import { ThemeProvider, useThemeContext } from './ThemeContext';

function DashboardHeader() {
  const { resolvedTheme, toggleTheme } = useThemeContext();
  
  return (
    <header className="dashboard-header">
      <div className="logo">
        <h1>Analytics Dashboard</h1>
      </div>
      <nav className="header-nav">
        <button 
          onClick={toggleTheme}
          className="theme-toggle"
          aria-label="Toggle theme"
        >
          {resolvedTheme === 'dark' ? '☀️' : '🌙'}
        </button>
      </nav>
    </header>
  );
}

function MetricsCard({ title, value, trend }: {
  title: string;
  value: string;
  trend: number;
}) {
  const { resolvedTheme } = useThemeContext();
  
  return (
    <div className="metrics-card">
      <h3>{title}</h3>
      <div className="value">{value}</div>
      <div className={`trend ${trend >= 0 ? 'positive' : 'negative'}`}>
        {trend >= 0 ? '↑' : '↓'} {Math.abs(trend)}%
      </div>
    </div>
  );
}

function Dashboard() {
  return (
    <ThemeProvider defaultTheme="auto" storageKey="dashboard-theme">
      <div className="dashboard">
        <DashboardHeader />
        <main className="dashboard-content">
          <div className="metrics-grid">
            <MetricsCard title="Active Users" value="12,458" trend={8.2} />
            <MetricsCard title="Revenue" value="¥342,890" trend={-2.4} />
            <MetricsCard title="Conversion Rate" value="3.24%" trend={5.7} />
          </div>
        </main>
      </div>
    </ThemeProvider>
  );
}

export default Dashboard;

# JavaScript Implementation

javascript
import { ThemeProvider, useThemeContext } from './ThemeContext';

function DashboardHeader() {
  const { resolvedTheme, toggleTheme } = useThemeContext();
  
  return (
    <header className="dashboard-header">
      <div className="logo">
        <h1>Analytics Dashboard</h1>
      </div>
      <nav className="header-nav">
        <button 
          onClick={toggleTheme}
          className="theme-toggle"
          aria-label="Toggle theme"
        >
          {resolvedTheme === 'dark' ? '☀️' : '🌙'}
        </button>
      </nav>
    </header>
  );
}

function MetricsCard({ title, value, trend }) {
  const { resolvedTheme } = useThemeContext();
  
  return (
    <div className="metrics-card">
      <h3>{title}</h3>
      <div className="value">{value}</div>
      <div className={`trend ${trend >= 0 ? 'positive' : 'negative'}`}>
        {trend >= 0 ? '↑' : '↓'} {Math.abs(trend)}%
      </div>
    </div>
  );
}

function Dashboard() {
  return (
    <ThemeProvider defaultTheme="auto" storageKey="dashboard-theme">
      <div className="dashboard">
        <DashboardHeader />
        <main className="dashboard-content">
          <div className="metrics-grid">
            <MetricsCard title="Active Users" value="12,458" trend={8.2} />
            <MetricsCard title="Revenue" value="¥342,890" trend={-2.4} />
            <MetricsCard title="Conversion Rate" value="3.24%" trend={5.7} />
          </div>
        </main>
      </div>
    </ThemeProvider>
  );
}

export default Dashboard;

This dashboard demonstrates several practical considerations:

Performance: Components only re-render when theme actually changes, not on every state update elsewhere in the app.

Accessibility: The theme toggle button includes an aria-label for screen readers.

User Experience: The emoji changes based on current theme—moon for light mode (suggesting switching to dark), sun for dark mode (suggesting switching to light).

Real-world data: Metrics cards show how theme affects different UI components with varying amounts of data and visual complexity.

In production, you'd pair this with CSS variables for smooth theme transitions:

css
[data-theme="light"] {
  --bg-primary: #ffffff;
  --text-primary: #1a1a1a;
  --card-bg: #f5f5f5;
}

[data-theme="dark"] {
  --bg-primary: #1a1a1a;
  --text-primary: #ffffff;
  --card-bg: #2a2a2a;
}

* {
  transition: background-color 0.2s ease, color 0.2s ease;
}

# FAQ

# When should I use a custom hook vs. just useState in my component?

Use a custom hook when you need theme state in multiple components or when the logic involves more than just toggling a value. If only one component needs theme state and it's just tracking light/dark with no persistence, useState is fine. But as soon as you add localStorage, system preferences, or need to share state across components, a custom hook becomes the better choice.

# Why use useEffect for localStorage instead of updating it in toggleTheme?

Separation of concerns. toggleTheme is responsible for one thing: updating theme state. The effect handles the side effect of syncing to localStorage and the DOM. This makes the code easier to test and modify. You can add more effects later (like analytics tracking) without modifying the toggle function.

# Does this work with server-side rendering (Next.js, Remix)?

Partially. The localStorage and window.matchMedia calls will fail during SSR since those APIs don't exist on the server. You need to check typeof window !== 'undefined' before accessing them, and accept that the initial server render will use your default theme. Once the component hydrates on the client, it'll sync with the saved preference. For a flash-free experience, you'll need to inject a script tag that sets the theme before React loads.

# Can I add more than two themes (like high contrast mode)?

Yes. Change the Theme type to include additional values: type Theme = 'light' | 'dark' | 'high-contrast'. Then update the toggle logic to cycle through all options. You might want to replace toggleTheme with setTheme(theme: Theme) to give users direct control instead of forcing them to cycle.

# How do I test components that use useThemeContext?

Wrap your test components in the ThemeProvider and optionally pass a controlled defaultTheme. For integration tests, you can test the actual hook behavior. For unit tests of individual components, you might mock useThemeContext to return specific values and verify the component renders correctly for each theme.


Related Articles:

Questions? Share your theme implementation challenges in the comments below!

Sponsored Content

Google AdSense Placeholder

CONTENT SLOT

Sponsored

Google AdSense Placeholder

FOOTER SLOT