Implementing Dark Mode in React
Tags: react, javascript, css, webdev
Published: 2022-06-13 19:42:24

Hola folks! These days we all want to have the dark mode feature in our websites and applications. And why shouldn't we? It is more soothing to the eyes of the user, and we as UI/UX developers should tend to every need of our user.

But, how do we implement this dark mode feature in React? There are many things a developer is supposed to take care of while implementing this feature:

- User Preference 👤
    - Use the system preference if the user is visiting for the first time.
    - Use the user-preferred theme if the user has set it before.
    - Store the user-preferred theme.
- Toggle theme preference 🤹
    - Users should be able to toggle between different themes.
- Avoiding `the Flicker` 🔦
    - This flicker is eye-blinding and gives a bad user experience.
- Access to the theme 🌕
    - The theme should be easily accessible across the whole application.

Let’s cater to the points mentioned above one by one & learn how to implement the dark mode feature in React.

## User Preference

### System-wide Theme Preference

Let us first try to access the user’s system-wide theme preference. We can do this with the help of the `prefers-color-scheme` media feature. We can pass this media feature with the theme values `light` & `dark` to know if the user has set any system-wide theme preference. 

Now, we use the `matchMedia` window method to check whether the document matches the passed media query string.

```jsx
const preferColorSchemeQuery = "(prefers-color-scheme: dark)";
const theme = matchMedia(preferColorSchemeQuery).matches ? "dark" : "light";
```

### User-preferred Theme

In a case where the user has already visited our application & has set some theme preference, we need to store this theme preference & retrieve it every time the user visits our application. We will use the local storage to store the user’s theme preference.

```jsx
localStorage.setItem("theme", "dark"); // or "light"
localStorage.getItem("theme");
```

This user-preferred theme is to be given *priority* over the system-wide theme preference. Therefore, the code will look as follows:

```jsx
const preferColorSchemeQuery = "(prefers-color-scheme: dark)";
const theme = localStorage.getItem("theme") || 
	(matchMedia(preferColorSchemeQuery).matches ? "dark" : "light");
```

## Toggle theme preference

The user should be able to toggle between different themes. This feature can be easily provided with the help of a checkbox input & a theme state.

```jsx
// App.js

const preferColorSchemeQuery = "(prefers-color-scheme: dark)";

const giveInitialTheme = () => 
	localStorage.getItem("theme") || 
	(matchMedia(preferColorSchemeQuery).matches ? "dark" : "light");

const App = () => {
	const [theme, setTheme] = useState(giveInitialTheme());

	const toggleTheme = () => 
		setTheme((theme) => (theme === "light" ? "dark" : "light"));

	useEffect(() => {
		localStorage.setItem("theme", theme);
	}, [theme]);

	return (
		<input
      type="checkbox"
      name="theme-toggle"
      id="theme-toggle"
      checked={theme && theme === "dark"}
      onChange={toggleTheme}
		/>
	);
}
```

Here, we also have to make sure to update the local storage value of the theme. We do this with the help of the `useEffect` hook. `useEffect` runs *after* React renders the component and ensures that the effect callback does not block the browser’s visual painting.

## Avoiding the flicker

To avoid the famous *flicker* we need to perform the DOM updates before React renders the component & the browser paints the visual screen. But, as we have seen above `useEffect` can only help us perform operations after the render has been committed to the screen. Hence, the *flicker*.

Let me introduce you to another hook, `useLayoutEffect`. The syntax for this hook is identical to that of `useEffect`. The callback passed to this hook runs synchronously immediately after React has performed all DOM mutations. The code runs immediately after the DOM has been updated, but before the browser has had a chance to *paint* those changes.

<aside>
⚠️ **Warning**
Prefer the standard `useEffect` when possible to avoid blocking visual updates.

</aside>

So, we will be performing our updates with the help of `useLayoutEffect`.

### What updates?

We will have to update our CSS to match the current theme. Seems like a big task, doesn’t it? There are many ways to update the CSS, but, we will go forward with the most efficient way, i.e. **CSS Variables** or  **Custom Properties**.

CSS variables are entities defined by CSS authors that contain specific values to be reused throughout a document. They are set using custom property notation (e.g., **`--main-color: black;`**) and are accessed using the `var()`function (e.g., `color: var(--main-color);`).

We can also use HTML `data-*` attributes with CSS to match the data attribute & apply styles accordingly. In our case, depending on the `data-theme` attribute value, different colors will be applied to our page.

```css
/* index.css */

[data-theme="light"] {
	--color-foreground-accent: #111111;
	--color-foreground: #000000;
	--color-background: #ffffff;
}

[data-theme="dark"] {
	--color-foreground-accent: #eeeeee;
	--color-foreground: #ffffff;
	--color-background: #000000;
}

.app {
	background: var(--color-background);
	color: var(--color-foreground);
}
```

Our application code now will look something like this:

```jsx
// App.js

const preferColorSchemeQuery = "(prefers-color-scheme: dark)";

const giveInitialTheme = () => 
	localStorage.getItem("theme") || 
	(matchMedia(preferColorSchemeQuery).matches ? "dark" : "light");

const App = () => {
	const [theme, setTheme] = useState(giveInitialTheme());

	const toggleTheme = () => 
		setTheme((theme) => (theme === "light" ? "dark" : "light"));

	useEffect(() => {
		localStorage.setItem("theme", theme);
	}, [theme]);

	useLayoutEffect(() => {
    if (theme === "light") {
      document.documentElement.setAttribute("data-theme", "light");
    } else {
      document.documentElement.setAttribute("data-theme", "dark");
    }
  }, [theme]);

	return (
		<input
      type="checkbox"
      name="theme-toggle"
      id="theme-toggle"
      checked={theme && theme === "dark"}
      onChange={toggleTheme}
		/>
	);
}
```

## Access to the theme

The theme value might be needed anywhere across the application. We have to take care of this too. For this purpose, we store our theme value in a context & wrap its provider around the `App` component.

```jsx
// theme-context.js

// create theme context
const ThemeContext = createContext();

const preferColorSchemeQuery = "(prefers-color-scheme: dark)";

const giveInitialTheme = () => 
	localStorage.getItem("theme") || 
	(matchMedia(preferColorSchemeQuery).matches ? "dark" : "light");

// theme context provider
const ThemeProvider = ({ children }) => {
	const [theme, setTheme] = useState(giveInitialTheme());

	const toggleTheme = () => 
		setTheme((theme) => (theme === "light" ? "dark" : "light"));
	
	useEffect(() => {
		localStorage.setItem("theme", theme);
	}, [theme]);

	useLayoutEffect(() => {
    if (theme === "light") {
      document.documentElement.setAttribute("data-theme", "light");
    } else {
      document.documentElement.setAttribute("data-theme", "dark");
    }
  }, [theme]);

	return (
    <ThemeContext.Provider value={{ theme, toggleTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}

// custom hook to avail theme value
const useTheme = () => {
  const context = useContext(ThemeContext);

  if (context === undefined) {
    throw new Error("useTheme must be used within a ThemeProvider");
  }

  return context;
};

// exports
export { ThemeProvider, useTheme };
```

Congratulations! We are done with the implementation. You now know how to implement **Dark Mode** in your React application. Go and implement this super cool feature in your application now. 🥳

## Extra feature

Consider a case, where the user changes the system-wide theme preference while he/she is using your application. In the implementation above, the application won’t be able to detect these changes. If you want your application to detect these changes, we will need to set up a `change` event listener on this system-wide theme preference. We can do this with the help of the `useEffect` hook.

```jsx
useEffect(() => {
    const mediaQuery = matchMedia(preferColorSchemeQuery);
    const handleColorSchemeChange = () =>
      setTheme(mediaQuery.matches ? "dark" : "light");
    mediaQuery.addEventListener("change", handleColorSchemeChange);

    return () =>
      mediaQuery.removeEventListener("change", handleColorSchemeChange);
}, []);
```

We add a `change` event listener to the `mediaQuery` on the mount. The final theme context will look something like this:

```jsx
// theme-context.js

// create theme context
const ThemeContext = createContext();

const preferColorSchemeQuery = "(prefers-color-scheme: dark)";

const giveInitialTheme = () => 
	localStorage.getItem("theme") || 
	(matchMedia(preferColorSchemeQuery).matches ? "dark" : "light");

// theme context provider
const ThemeProvider = ({ children }) => {
	const [theme, setTheme] = useState(giveInitialTheme());

	const toggleTheme = () => 
		setTheme((theme) => (theme === "light" ? "dark" : "light"));
	
	useEffect(() => {
	    const mediaQuery = matchMedia(preferColorSchemeQuery);
	    const handleColorSchemeChange = () =>
	      setTheme(mediaQuery.matches ? "dark" : "light");
	    mediaQuery.addEventListener("change", handleColorSchemeChange);

	    return () =>
	      mediaQuery.removeEventListener("change", handleColorSchemeChange);
	}, [])

	useEffect(() => {
		localStorage.setItem("theme", theme);
	}, [theme]);

	useLayoutEffect(() => {
    if (theme === "light") {
      document.documentElement.setAttribute("data-theme", "light");
    } else {
      document.documentElement.setAttribute("data-theme", "dark");
    }
  }, [theme]);

	return (
    <ThemeContext.Provider value={{ theme, toggleTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}

// custom hook to avail theme value
const useTheme = () => {
  const context = useContext(ThemeContext);

  if (context === undefined) {
    throw new Error("useTheme must be used within a ThemeProvider");
  }

  return context;
};

// exports
export { ThemeProvider, useTheme };
```

You can refer to the Codesandbox below:
{% codesandbox 5uh8fz %}

Please feel free to share your feedback in the comments section. You can connect with me on [Twitter](https://twitter.com/horsemaker_) or [LinkedIn](https://www.linkedin.com/in/yashghodekar/).

Happy hacking! Keep learning! 😎

## References

- [https://kentcdodds.com/blog/useeffect-vs-uselayouteffect](https://kentcdodds.com/blog/useeffect-vs-uselayouteffect)
- [https://reactjs.org/docs/hooks-reference.html#uselayouteffect](https://reactjs.org/docs/hooks-reference.html#uselayouteffect)
- [https://usehooks.com/useTheme/](https://usehooks.com/useTheme/)