Light/dark mode: React implementation

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

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

Explanations

HTML

Color scheme

As in all other posts of this series, we have the following in the head, ensuring that native elements will respond to the correct theme (and the id is for changing its value from the JS):

<meta id="colorScheme" name="color-scheme" content="light dark" />

CSS

I went with something simple for the CSS: 2 classnames light and dark, and I'm updating 2 css variables, than in the end control the look of the main body:

body.light {
  --color: #111;
  --background: #fff;
}
body.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:

<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.body.classList.add(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):

// 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:

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, that will only get called during the 1st render:

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:

// 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, so that the effect will be called every time the mode changes:

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:

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:

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:

React.useEffect(() => {
  // Clear previous classNames on the body and add the new one
  document.body.classList.remove("light");
  document.body.classList.remove("dark");
  document.body.classList.add(theme);

  // change <meta name="color-scheme"> for native inputs
  (document.getElementById("colorScheme") as HTMLMetaElement).content = 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:

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

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

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.

21