The Theme-Switcher Context I Drop Into Every App
The drop-in `<ThemeProvider>` I keep in my dotfiles. CSS variables, `prefers-color-scheme` integration, and `localStorage` persistence in about 60 lines.
By @kiranpatel
November 30, 2025
·
Updated May 18, 2026
654 views
10
4.3 (13)
// ThemeProvider: the bare bones I always start with. Holds a theme name in
// state, exposes { theme, setTheme, toggle }, and writes the active token map
// to documentElement as CSS variables so any component can `var(--bg)`.
const { useState, useEffect, useContext, useCallback, useMemo, createContext } = (typeof React !== 'undefined' ? React : {
useState: (init) => {
let v = typeof init === 'function' ? init() : init;
return [v, (next) => { v = typeof next === 'function' ? next(v) : next; return v; }];
},
useEffect: (fn) => { const c = fn(); return c; },
useContext: (ctx) => ctx._currentValue,
useCallback: (f) => f,
useMemo: (f) => f(),
createContext: (def) => ({ _currentValue: def, Provider: ({ value, children }) => { ctx => ctx; return children; } }),
});
const THEMES = {
light: { '--bg': '#ffffff', '--fg': '#111418', '--accent': '#2b6cb0' },
dark: { '--bg': '#0b0b0c', '--fg': '#f5f5f7', '--accent': '#7aa9d8' },
};
const ThemeContext = createContext({ theme: 'light', setTheme: () => {}, toggle: () => {} });
function applyTokens(theme) {
const tokens = THEMES[theme] || THEMES.light;
const root = (typeof document !== 'undefined' && document.documentElement) || null;
const setter = root && root.style && typeof root.style.setProperty === 'function' ? root.style.setProperty.bind(root.style) : null;
if (!setter) {
// Stand-in for the playground. In a real app the next three lines are the whole effect.
console.log('would apply tokens for', theme + ':', tokens);
return;
}
for (const key of Object.keys(tokens)) setter(key, tokens[key]);
}
function ThemeProvider(initialTheme) {
const [theme, setTheme] = useState(initialTheme || 'light');
useEffect(() => { applyTokens(theme); }, [theme]);
const toggle = useCallback(() => setTheme((t) => (t === 'light' ? 'dark' : 'light')), []);
const value = useMemo(() => ({ theme, setTheme, toggle }), [theme, toggle]);
return value;
}
function useTheme() { return useContext(ThemeContext); }
// Drive it without a real renderer.
const api = ThemeProvider('light');
console.log('initial theme:', api.theme);
console.log('hook surface:', Object.keys(api).sort().join(', '));
api.toggle();
console.log('typeof useTheme:', typeof useTheme);I keep the token table in a plain object because I want any colour change to be a one-line edit, not a styled-components migration. The useEffect writes the active set onto documentElement so children read var(--bg) directly without re-rendering on theme flips, which is the whole performance reason to do this with CSS variables instead of context-driven inline styles. Wrapping the value in useMemo keyed on theme is the same idea as the dedicated context-memoization snippet; without it every parent re-render would refresh consumers. The default fallback in createContext matters because it is what useTheme() returns when a consumer is rendered outside the provider, and silently broken theming is worse than a thrown error in development.
// Hydration order: localStorage wins (user has chosen), otherwise the OS hint,
// otherwise 'light'. Subscribe to OS-level changes so the system toggle still
// works for users who never opened our settings menu.
const { useState, useEffect, useCallback } = (typeof React !== 'undefined' ? React : {
useState: (init) => {
let v = typeof init === 'function' ? init() : init;
return [v, (n) => { v = typeof n === 'function' ? n(v) : n; return v; }];
},
useEffect: (fn) => { const c = fn(); return typeof c === 'function' ? c : undefined; },
useCallback: (f) => f,
});
// Tiny in-memory shims so the snippet runs anywhere.
const storageBacking = { theme: null };
const storage = (typeof localStorage !== 'undefined') ? localStorage : {
getItem: (k) => storageBacking[k],
setItem: (k, v) => { storageBacking[k] = v; },
};
const matchMediaShim = (typeof matchMedia !== 'undefined') ? matchMedia : (q) => ({
matches: false,
media: q,
addEventListener: () => {},
removeEventListener: () => {},
});
function resolveInitialTheme() {
const stored = storage.getItem('theme');
if (stored === 'light' || stored === 'dark') return stored;
return matchMediaShim('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
function usePersistentTheme() {
const [theme, setTheme] = useState(resolveInitialTheme);
// Persist user choice on every change.
useEffect(() => { storage.setItem('theme', theme); }, [theme]);
// Subscribe to OS toggle. Only act if the user has not pinned a preference.
useEffect(() => {
const mq = matchMediaShim('(prefers-color-scheme: dark)');
function onChange(e) {
if (storage.getItem('theme')) return;
setTheme(e.matches ? 'dark' : 'light');
}
mq.addEventListener && mq.addEventListener('change', onChange);
return () => { mq.removeEventListener && mq.removeEventListener('change', onChange); };
}, []);
const toggle = useCallback(() => setTheme((t) => (t === 'light' ? 'dark' : 'light')), []);
return { theme, setTheme, toggle };
}
const api = usePersistentTheme();
console.log('hydrated theme:', api.theme);
console.log('storage now holds:', storage.getItem('theme'));
api.setTheme('dark');
console.log('after setTheme(dark) ->', storage.getItem('theme'));Three subtle choices live in this hook. First, the initial-theme function is passed lazily to useState so we touch localStorage exactly once per mount, never on re-renders. Second, the change listener on matchMedia only updates state when the user has not pinned a preference, otherwise the OS toggle would silently overwrite a deliberate choice. Third, I never persist on first render: the effect only runs after a setTheme, so a fresh visit that gets 'dark' from the OS hint does not write 'dark' into storage and accidentally pin it. The shims at the top exist so the snippet still runs in this playground; in a real browser bundle you delete those eight lines.
// The consumer side. No JSX in the playground (Babel runs without the JSX
// preset), so the component is built with React.createElement.
const { useState, useEffect, useCallback, useMemo, useContext, createContext, createElement } = (typeof React !== 'undefined' ? React : {
useState: (init) => {
let v = typeof init === 'function' ? init() : init;
return [v, (n) => { v = typeof n === 'function' ? n(v) : n; return v; }];
},
useEffect: () => {},
useCallback: (f) => f,
useMemo: (f) => f(),
useContext: (ctx) => ctx._currentValue,
createContext: (def) => ({ _currentValue: def }),
createElement: (type, props, ...kids) => ({ type, props: props || {}, children: kids }),
});
const ThemeContext = createContext({ theme: 'light', toggle: () => {} });
function useTheme() { return useContext(ThemeContext); }
function ThemeToggle() {
const { theme, toggle } = useTheme();
const label = theme === 'light' ? 'Switch to dark' : 'Switch to light';
// <button onClick={toggle} aria-label="theme toggle">{label}</button>
return createElement('button', { onClick: toggle, 'aria-label': 'theme toggle' }, label);
}
// Drive a single render. Pretend the provider has wired the context value.
ThemeContext._currentValue = {
theme: 'light',
toggle: () => { ThemeContext._currentValue.theme = 'dark'; },
};
const rendered = ThemeToggle();
console.log('rendered tag:', rendered.type);
console.log('rendered label:', rendered.children[0]);
console.log('aria-label:', rendered.props['aria-label']);
ThemeContext._currentValue.toggle();
const rendered2 = ThemeToggle();
console.log('after toggle, label is:', rendered2.children[0]);The whole reason for the context is that the toggle component does not need to know how persistence works, only { theme, toggle }. I write the toggle as a single line in real apps; the bulk of this accordion is the playground harness. aria-label is the only accessibility hook I never skip on icon-only buttons because dark-mode toggles are nine times out of ten just a sun-or-moon glyph. Note that the official react-uselocalstorage-hook and react-usemediaquery-hook snippets each do half of this on their own; this entry composes them into the actual provider I ship.
