Light/dark mode: React implementation
Tags: webdev, css, javascript, react
Published: 2021-06-24 15:16:28

## Introduction

In the previous posts, we saw how to:
- use CSS to handle different themes,
- handle system themes and also user-picked themes,
- store the previously picked theme for next visits,
- how to avoid theme blink on page reload.

In this post, we'll see how we can use everything together, and add **React** and a remote database (for fun) in this mix.
The goal is to show the backbone of what could be the actual code you'd use to handle themes in your app.

## Table of contents

1. [Flow of the logic we'll implement](#flow-of-the-logic-well-implement)
   1. [First visit ever](#first-visit-ever)
   2. [First visit on a new browser](#first-visit-on-a-new-browser)
   3. [Re-visit](#re-visit)
2. [Results](#results)
3. [Explanations](#explanations)
   1. [HTML](#html)
      1. [CSS](#css)
      2. [Blocking script](#blocking-script)
   2. [JavaScript](#javascript)
      1. [Base variables](#base-variables)
      2. [React context](#react-context)
      3. [Initialization of the mode](#initialization-of-the-mode)
      4. [Database sync](#database-sync)
      5. [Save back the mode](#save-back-the-mode)
      6. [Initialization of the mode](#initialization-of-the-mode-1)
      7. [System theme update](#system-theme-update)
      8. [Apply the theme back to the HTML](#apply-the-theme-back-to-the-html)
      9. [Defining the context](#defining-the-context)
4. [Conclusion](#conclusion)

## Flow of the logic we'll implement

The following flow is related to a frontend app, not a server-side rendered website (like what you would have in PHP):

1. Users are loading your website
2. We are applying (in a blocking way) the previously picked theme (it can be a wrong one)
3. A fetch is performed on your database to retrieve their favorite mode (light/dark/system)
4. The favorite mode is saved in their browser for future visits
5. The mode is saved in a react context (for reactive updates if needed)
6. When the mode changes, it is saved locally (for future uses), a request is performed against your database, and the react context is updated.

### First visit ever

Your users won't have any entry in your database and they won't have any local data saved either. So we'll use the system mode as a fallback.

### First visit on a new browser

Your users won't have any local data, so while the request is being done against your database to retrieve their preferred mode, we'll use the system one to avoid unwanted flashes.

### Re-visit

The mode they previously picked on this browser will be initially picked. And then 2 possibilities:
- they haven't changed their preferred mode on another device, so the local one matches the remote one => no differences and no flashes (this is the flow during a page refresh),
- they have changed it, and here we'll have a small flash at the initial re-visit (but we cannot prevent that)


## Results

{% codesandbox tbclf %}

## Explanations

### HTML


#### CSS

I went with something simple for the CSS: a data-attribute `data-theme` with 2 values `light` and `dark`, and I'm updating 2 css variables, than in the end control the look of the main body.


And as in all other posts of this series, we need to set the `color-scheme`, ensuring that native elements will respond to the correct theme:

```css
:root[data-theme="light"] {
  color-scheme: light;
  --color: #111;
  --background: #fff;
}
:root[data-theme="dark"] {
  color-scheme: dark;
  --color: #cecece;
  --background: #333;
}
body {
  color: var(--color);
  background: var(--background);
}
```


#### Blocking script

As we want to avoid flicker during page loads, I added a small **blocking** script tag, performing only **synchronous actions**, that only checks for the most **basic** requirements to determine to best theme to display:

```html
<script>
  const mode = localStorage.getItem("mode") || "system";
  let theme;
  if (mode === "system") {
    const isSystemInDarkMode = matchMedia("(prefers-color-scheme: dark)")
      .matches;
    theme = isSystemInDarkMode ? "dark" : "light";
  } else {
    // for light and dark, the theme is the mode
    theme = mode;
  }
  document.documentElement.dataset.theme = theme;
</script>
```

### JavaScript

#### Base variables

First, we need to determine our variables: I'm gonna use `mode` for the saved modes (light / dark / system), and `theme` for the visual themes (light / dark):

```ts
// Saved mode
type Mode = "light" | "dark" | "system";
// Visual themes
type Theme = "light" | "dark";
```

#### React context

As we want to be able to provide some informations about the current mode/theme and also a way for users to change the mode, we'll create a React context containing everything:

```ts
const ThemeContext = React.createContext<{
  mode: Mode;
  theme: Theme;
  setMode: (mode: Mode) => void;
}>({
  mode: "system",
  theme: "light",
  setMode: () => {}
});
```

#### Initialization of the mode

We'll use a state (as its value can change and it should trigger updates) to store the mode.
With `React.useState`, you can provide a function, called a [lazy initial state](https://reactjs.org/docs/hooks-reference.html#lazy-initial-state), that will only get called during the 1st render:

```ts
const [mode, setMode] = React.useState<Mode>(() => {
  const initialMode =
    (localStorage.getItem(localStorageKey) as Mode | undefined) || "system";
  return initialMode;
});
```

#### Database sync

Now that we have a `mode` state, we need to update it with the remote database. To do so, we could use an effect, but I decided to use another `useState`, which seems weird as I'm not using the returned state, but as mentioned above, lazy initial states are only called during the 1st render.
This allows us to start the backend call during the render, and not after in an effect. And as we're starting the API call earlier, we'll also receive the response faster:

```ts
// This will only get called during the 1st render
React.useState(() => {
  getMode().then(setMode);
});
```

#### Save back the mode

When the mode changes, we want to:
- save it in the local storage (to avoid flashes on reload)
- in the database (for cross-device support)

An effect is the perfect use-case for that: we pass the `mode` in the [dependencies array](https://reactjs.org/docs/hooks-reference.html#conditionally-firing-an-effect), so that the effect will be called every time the mode changes:

```ts
React.useEffect(() => {
  localStorage.setItem(localStorageKey, mode);
  saveMode(mode); // database
}, [mode]);
```

#### Initialization of the mode

Now that we have a way to get, save, and update the mode, we need a way to translate it to a visual theme.
For this we will use another state (because theme change should trigger an update).

We'll use another lazy initial state to synchronize the `system` mode with the theme users picked for their devices:


```ts
const [theme, setTheme] = React.useState<Theme>(() => {
  if (mode !== "system") {
    return mode;
  }
  const isSystemInDarkMode = matchMedia("(prefers-color-scheme: dark)")
    .matches;
  return isSystemInDarkMode ? "dark" : "light";
});
```

#### System theme update

If users picked the `system` mode, we need to track down if they decide to change it from light to dark while still being in our system mode (which is why we are also using a state for the `theme`).

To do so, we'll also use an effect that will detect any changes in the mode. In addition to that, when users are in the `system` mode, we'll get their current system theme and start an event listener to detect any changes in their theme:

```ts
React.useEffect(() => {
  if (mode !== "system") {
    setTheme(mode);
    return;
  }

  const isSystemInDarkMode = matchMedia("(prefers-color-scheme: dark)");
  // If system mode, immediately change theme according to the current system value
  setTheme(isSystemInDarkMode.matches ? "dark" : "light");

  // As the system value can change, we define an event listener when in system mode
  // to track down its changes
  const listener = (event: MediaQueryListEvent) => {
    setTheme(event.matches ? "dark" : "light");
  };
  isSystemInDarkMode.addListener(listener);
  return () => {
    isSystemInDarkMode.removeListener(listener);
  };
}, [mode]);
```

#### Apply the theme back to the HTML

Now that we have a reliable `theme` state, we can make so that the CSS and the HTML follows this state:

```ts
React.useEffect(() => {
  // Clear previous theme on the html and set the new one
  document.documentElement.dataset.theme = theme;
}, [theme]);
```

#### Defining the context

Now that we have all the variables we need, the last thing to do is to wrap the whole app in a context provider:

```tsx
<ThemeContext.Provider value={{ theme, mode, setMode }}>
  {children}
</ThemeContext.Provider>
```

And when we need to refer to it, we can do:

```ts
const { theme, mode, setMode } = React.useContext(ThemeContext);
```

## Conclusion

Handling multiple themes isn't trivial, especially if you want to provide the best experience possible for users while having handy tools for your fellow developers.

Here I only presented one possible way of handling this, and it can be refined, improved, and expanded for other use-cases.

But even if your logic/requirements are different, the flow presented at the beginning shouldn't be that different from the one you should adopt.

And if you want to have a look at the full code I wrote in the example, you can find it here: https://codesandbox.io/s/themes-tbclf.