last updated: March 17, 2024
5 minute read
Implementing Dark Mode: A Client-Side Approach
Implementing a dark theme for a web app is a deceptively difficult task. As a dev, you need to manage everything from media queries to local storage to cookies - all with a clear understanding of which rendering patterns support which APIs. In this article and the next, we'll talk through two different approaches to consider when building a dark mode in your next project.
First, we'll build a client-side implementation using media queries to access os-level preferences, and local storage to persist the chosen theme across reloads. The crux of this approach is to access our data only on the client and render accordingly.
In the next article, we'll take a server-side approach, using cookies to store a client's chosen theme while ignoring any os-level preferences. Finally, in the second article's Bonus section, we'll discuss a hybrid approach to account for os-level preferences using both cookies and media queries.
With that introduction out of the way, let's get started!
tl;dr:
import { useEffect, useState } from "react";const key = "isDarkMode";export default function useDarkMode() {const [isDarkMode, setIsDarkMode] = useState<boolean>(() => {const item = window.localStorage.getItem(key);if (item !== null) {return JSON.parse(item);}const media = window.matchMedia("(prefers-color-scheme: dark)");return media.matches;});useEffect(() => {window.localStorage.setItem(key, JSON.stringify(isDarkMode));}, [isDarkMode]);return [isDarkMode, setIsDarkMode] as const;}
or the "safe" version to handle unexpected errors with JSON
or window.localStorage
methods:
import { useEffect, useState } from "react";const key = "isDarkMode";const safelySetStorage = (valueToStore: unknown) => {try {window.localStorage.setItem(key, JSON.stringify(valueToStore));} catch {}};const safelyGetStorage = <T,>() => {try {return JSON.parse(window.localStorage.getItem(key) || "null") as T | null;} catch {return null;}};export default function useDarkMode() {const [isDarkMode, setIsDarkMode] = useState<boolean>(() => {const item = safelyGetStorage<boolean>();if (item !== null) {return item;}const media = window.matchMedia("(prefers-color-scheme: dark)");return media.matches;});useEffect(() => {safelySetStorage(isDarkMode);}, [isDarkMode]);return [isDarkMode, setIsDarkMode] as const;}
Nuances of the approach above
There's a few interesting behaviors the useDarkMode
hook above implements that are worth pointing
out.
First, it calculates the initial value of isDarkMode
using a callback passed to useState
. As
explained in the updated React docs
If you pass a
function
as initialState, it will be treated as an initializer function. It should be pure, should take no arguments, and should return a value of any type. React will call your initializer function when initializing the component, and store its return value as the initial state. [...]
In our case, we want to determine the initial value of isDarkMode
based on the value set in local
storage or the result of the "(prefers-color-scheme: dark)"
media query. We first check local
storage because if the user manually sets a theme, we should respect it regardless of the os-level
preference - but let's dive a bit deeper into this.
Notice that in the useEffect
, we synchronize the isDarkMode
local storage with the isDarkMode
state whenever the latter changes. However, since a useEffect
runs after the first render, this
means that the local storage will immediately be set with the value returned by the useState
callback. In other words, our local storage will be set even if our code does not call
setIsDarkMode
! Are there any negative implications to this behavior? Maybe: the first time a user
visits the site, if they have no os-level theme preference (or have a preference which matches the
default theme), we'll immediately set the local storage to the default theme. If the user later
sets/changes their os-level preference and revisits the site, our hook won't check for it, since the
local storage is already set.
If you find this behavior undesirable, there's a few ways to prevent it.
- Avoid synchronizing local storage with React state in a
useEffect
. Instead, write a customsetIsDarkMode
function to set the state and local storage in one pass.
export default function useDarkMode() {const [isDarkMode, _setIsDarkMode] = useState<boolean>(() => {// ...});const setIsDarkMode = useCallback((valOrFunc: boolean | ((prevVal: boolean) => boolean)) => {const nextVal = typeof valOrFunc === "function" ? valOrFunc(isDarkMode) : valOrFunc;_setIsDarkMode(nextVal);window.localStorage.setItem(key, JSON.stringify(nextVal));},[isDarkMode],);return [isDarkMode, setIsDarkMode] as const;}
- Add a flag to prevent the
useEffect
from running on the first render.
export default function useDarkMode() {// ...const isFirstRender = useRef(true);useEffect(() => {if (isFirstRender.current) {isFirstRender.current = false;return;}// ...}, [isDarkMode]);return [isDarkMode, setIsDarkMode] as const;}
Note that when wrapping your app with <StrictMode />
in React 18, a useEffect
will run twice
on-mount in development, effectively negating our isFirstRender
ref. Effects still only run once
on-mount in production, though.
I'll leave it up to the reader to decide if the simplicity of the original hook is worth the trade offs discussed.
Pitfalls of this approach on the server
It's worth noting that our useDarkMode
hook won't work as expected when rendered on the server,
primarily because of the callback passed to useState
. On the server, the rendering environment
simply doesn't have access to the window
object, and our hook will throw when we attempt to use
the localStorage
property on an undefined
instance of window
.
However, since a useEffect
only runs on the client, we would have no issues have accessing
window.localStorage
there - if only we didn't error-out on our initial render. Could we take
advantage of the fact that useEffect
only runs on the client and move the media query to the
useEffect
instead? We could, but that has its own trade offs:
import { useEffect, useState } from "react";const key = "isDarkMode";export default function useDarkMode() {const [isDarkMode, setIsDarkMode] = useState<boolean | null>(null);useEffect(() => {if (isDarkMode === null) {const item = window.localStorage.getItem(key);if (item !== null) {setIsDarkMode(JSON.parse(item));return;}const media = window.matchMedia("(prefers-color-scheme: dark)");setIsDarkMode(media.matches);return;}window.localStorage.setItem(key, JSON.stringify(isDarkMode));}, [isDarkMode]);return [isDarkMode ?? false, setIsDarkMode] as const;}
Something like the hook above successfully accesses client-only APIs like local storage and media
queries only when they're accessible, but at a heavy cost: when the hook runs on the server,
isDarkMode
is initialized to false
, and the HTML for the first render is built accordingly.
Later, on the client, if the setIsDarkMode
is called in the useEffect
with true
, you'll have a
flash between the light theme of the initial content and the dark theme of the app after the page is
re-rendered with the new theme. Tricky!
So how can we prevent the dreaded flash? Find out in Implementing Dark Mode: A Server-Side Approach!
you might also like:
Implementing Dark Mode: A Server-Side Approach with Cookies
March 17, 2024
A server-and-client approach using cookies