typescript 98 lines · 4 tabs

Persisting a Dark-Mode Theme Toggle with a React ThemeProvider and localStorage

Shared by codesnips Jul 2026
4 tabs
import { useEffect, useState } from 'react';

export function useLocalStorage<T>(
  key: string,
  initialValue: T
): [T, (value: T) => void] {
  const [stored, setStored] = useState<T>(() => {
    if (typeof window === 'undefined') return initialValue;
    try {
      const raw = window.localStorage.getItem(key);
      return raw ? (JSON.parse(raw) as T) : initialValue;
    } catch {
      return initialValue;
    }
  });

  useEffect(() => {
    if (typeof window === 'undefined') return;
    try {
      window.localStorage.setItem(key, JSON.stringify(stored));
    } catch {
      // storage may be full or blocked; ignore
    }
  }, [key, stored]);

  return [stored, setStored];
}
4 files · typescript Explain with highlit

This snippet shows the standard React pattern for a theme toggle that survives reloads: a Context provider owns the theme state, a custom hook reads and writes localStorage, and consumers flip the value without touching persistence details.

In useLocalStorage hook, useState is initialised lazily with a function so the localStorage read only happens once on mount rather than on every render. The read is wrapped in a try/catch because storage access throws in private-browsing modes and when quota is exceeded, and it is guarded by a typeof window check so the code is safe to import during server-side rendering, where window and localStorage do not exist. A useEffect mirrors any state change back into storage, which keeps React state as the single source of truth while treating storage as a durable side effect. The returned tuple mimics useState, so callers use it exactly like local state.

In ThemeContext, createContext is given a null default and the useTheme hook throws when the context is missing. This is a deliberate guard: a null default would otherwise let a component render outside the provider and fail later with a confusing error, so failing loudly at the boundary makes misuse obvious. The Theme union type restricts values to 'light' or 'dark', which prevents typos and gives autocomplete in consumers.

In ThemeProvider, the persisted value from useLocalStorage becomes the theme, and a useEffect applies it to document.documentElement via a data-theme attribute so CSS can drive the actual colors. toggleTheme is wrapped in useCallback, and the context value is memoised with useMemo, so consumers that only read theme do not re-render when unrelated parents update. The initial value falls back to prefers-color-scheme so first-time visitors get a sensible default before any explicit choice.

In ThemeToggle component, the button reads theme and calls toggleTheme, with aria-pressed reflecting state for screen readers. The trade-off of this design is a brief flash of the wrong theme on load unless the data-theme attribute is also set by a tiny inline script before hydration; for that reason production apps often pair this provider with a blocking head script.


Related snips

html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Semantic HTML Example</title>

Semantic HTML5 elements and accessibility best practices

html html5 semantics
by Alex Chang 2 tabs
javascript
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["form"]
  static values = { delay: { type: Number, default: 250 } }

Debounced live search with Stimulus + Turbo Streams

rails hotwire stimulus
by codesnips 4 tabs
typescript
import axios, { AxiosError } from 'axios'
import { v4 as uuidv4 } from 'uuid'

const api = axios.create({
  baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3000/api/v1',
  timeout: 15000,

Axios API client with interceptors

react axios api
by Maya Patel 1 tab
typescript
import { create } from 'zustand'
import { persist } from 'zustand/middleware'

interface FilterState {
  search: string
  category: string | null

Zustand for lightweight state management

react zustand state-management
by Maya Patel 2 tabs
html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Form Validation Example</title>
  <style>

HTML forms with validation and accessibility

html forms validation
by Alex Chang 1 tab
javascript
import { Application } from "@hotwired/stimulus"
import FormSubmitController from "./controllers/form_submit_controller"

const application = Application.start()
application.debug = false

Disable submit button while Turbo form is submitting

rails hotwire stimulus
by codesnips 3 tabs

Share this code

Here's the card — post it anywhere.

Persisting a Dark-Mode Theme Toggle with a React ThemeProvider and localStorage — share card
Link copied