Build a Persistent Light / Dark Theme in React

A common feature in modern web apps is a light / dark theme switch. In this example you created a compact, reliable solution using React and the browser localStorage. The theme choice persists across page reloads because it is saved to the user’s device.

This article explains the implementation line by line: the React components, a custom useLocalStorage hook, and the CSS that reads theme variables.

What the code does at a glance

  • Shows a page with a text and a button.
  • Clicking the button toggles between light and dark theme.
  • The current theme is saved to localStorage so it persists after reload.
  • CSS variables change colors depending on the theme.

Files involved:

  • LightDarkMode.jsx — main component
  • useLocalStorage.js — custom hook that syncs state with localStorage
  • theme.css — CSS variables and styling

Full code (for reference)

LightDarkMode.jsx

import { useEffect } from "react";
import useLocalStorage from "./useLocalStorage";
import './theme.css';

export default function LightDarkMode() {
const [theme, setTheme] = useLocalStorage('theme', 'dark');
function handleToogleTheme() {
setTheme(theme === 'light' ? 'dark' : 'light');
}
useEffect(() => {
console.log(theme);
},[theme]);
return (
<div className="light-dark-mode" data-theme={theme}>
<div className="container">
<p>Hello World !</p>
<button onClick={handleToogleTheme}>Change Theme</button>
</div>
</div>
)
}

useLocalStorage.js

import { useEffect, useState } from "react";

export default function useLocalStorage(key, defaultValue) {
const [value, setValue] = useState(() => {
let currentValue;
try {
currentValue = JSON.parse(localStorage.getItem(key) || String(defaultValue));
} catch(error) {
console.log(error);
currentValue = defaultValue;
}
return currentValue;
});
useEffect(() => {
localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue];
}

theme.css

:root{
--background:#ffffff;
--text-primary:#000;
--button-bg:#000;
--button-text:#ffffff;
}

[data-theme='dark'] {
--background:#000000;
--text-primary:#fff;
--button-bg:#fff;
--button-text:#000;
}
.light-dark-mode{
background-color: var(--background);
height: 100vh;
display: flex;
flex-direction: column;
justify-content: center;
font-size: 20px;
transition: all .5s;
}
.container{
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
gap: 30px;
}
.light-dark-mode .container p{
color: var(--text-primary);
font-size: 40px;
margin: 0px;
}
.light-dark-mode .container button {
background-color: var(--button-bg);
border: 1px solid var(--button-bg);
color: var(--button-text);
padding: 12px;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
}

Step-by-step explanation

The main component: LightDarkMode

const [theme, setTheme] = useLocalStorage('theme', 'dark');
  • Instead of useState, the component uses useLocalStorage (a custom hook).
  • This returns the current theme (theme) and a setter (setTheme).
  • The hook is called with a key (‘theme’) and a defaultValue (‘dark’). If nothing is in localStorage, the app starts in dark mode.
function handleToogleTheme() {
setTheme(theme === 'light' ? 'dark' : 'light');
}
  • This toggles the theme when the button is clicked.
  • setTheme updates state and also saves to localStorage (see the hook).
useEffect(() => {
console.log(theme);
}, [theme]);
  • This useEffect just logs the current theme whenever it changes. You can remove it in production or use it to trigger other side effects (for example analytics or updating a <meta> tag).
return <div className="light-dark-mode" data-theme={theme}> ... </div>
  • The component puts the data-theme attribute on the root element. CSS reads that attribute to switch variable values.
  • Using data-theme is clean: no global DOM manipulation and no inline styles. CSS variables do the rest.

The custom hook: useLocalStorage

This hook wraps useState and connects it to localStorage. It does two important things:

  1. Initialize the state from localStorage if a value exists.
  2. Save the state back to localStorage whenever it changes.

Key lines:

const [value, setValue] = useState(() => {
let currentValue;
try {
currentValue = JSON.parse(localStorage.getItem(key) || String(defaultValue));
} catch(error) {
console.log(error);
currentValue = defaultValue;
}
return currentValue;
});
  • The state initializer is a lazy initializer: passing a function to useState means the code runs only once (on mount). This avoids reading from localStorage on every render.
  • JSON.parse is used because we store the value as a JSON string. If localStorage has unexpected data or parsing fails, the catch ensures the hook returns the defaultValue instead of crashing.
useEffect(() => {
localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
  • Every time the state (value) changes, this useEffect saves the new value to localStorage.
  • JSON.stringify ensures objects, arrays, and strings can be stored safely.

Finally:

return [value, setValue];
  • The hook returns the state and setter so the component can read and update it like useState.

CSS: theme variables and usage

You define a set of CSS variables at the root and override them when data-theme=’dark’:

:root {
--background: #ffffff;
--text-primary: #000;
...
}
[data-theme='dark'] {
--background: #000000;
--text-primary: #fff;
...
}
  • These are custom properties. The component’s markup uses those variables through var(–background) and var(–text-primary).
  • Switching data-theme toggles the whole color scheme without rewriting individual styles.

Transitions:

.light-dark-mode {
transition: all .5s;
}
  • This provides a smooth color transition between themes.

Why this approach is good

  • Persistent: The theme is remembered across page reloads because it is stored in localStorage.
  • Simple: The logic is concise and easy to understand.
  • Performant: Lazy initialization prevents unnecessary work on each render.
  • CSS-first: Using CSS variables keeps styles maintainable and avoids inline styles or repeated class toggles.
  • Reusable hook: useLocalStorage is a generic hook you can reuse for other persistent settings (language, last visited page, user preferences).

Small improvements and suggestions

  • Accessibility: Add aria-pressed or a label to the button to indicate current state:
<button   onClick={handleToogleTheme}   aria-pressed={theme === 'dark'} >
Change Theme
</button>
  • Persist on system preference: You can default to the user’s OS theme using window.matchMedia(‘(prefers-color-scheme: dark)’).
  • Apply theme to <html> or <body>: If you want the entire page (outside the component) to use the theme, set data-theme on document.documentElement instead of the component root.
  • Avoid console.log in production: Remove or gate logging behind a debug flag.
  • Type safety: If you use TypeScript, type the hook and allowed theme values (e.g., ‘light’ | ‘dark’).

What the current code does

  • The component toggles a theme and saves it using useLocalStorage.
  • The custom hook initializes state from localStorage and keeps it in sync.
  • CSS variables switch color values based on the data-theme attribute.
  • The result is a small, maintainable, and user-friendly theme switcher that persists across reloads.

SEE FULL CODE HERE

Happy coding !!


Build a Persistent Light / Dark Theme in React was originally published in Javarevisited on Medium, where people are continuing the conversation by highlighting and responding to this story.

This post first appeared on Read More