DEV Community

Ayc0
Ayc0

Posted on • Edited on

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

  1. Flow of the logic we'll implement
    1. First visit ever
    2. First visit on a new browser
    3. Re-visit
  2. Results
  3. Explanations
    1. HTML
      1. CSS
      2. Blocking script
    2. JavaScript
      1. Base variables
      2. React context
      3. Initialization of the mode
      4. Database sync
      5. Save back the mode
      6. Initialization of the mode
      7. System theme update
      8. Apply the theme back to the HTML
      9. Defining the context
  4. 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

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:

: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);
}
Enter fullscreen mode Exit fullscreen mode

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.documentElement.dataset.theme = theme;
</script>
Enter fullscreen mode Exit fullscreen mode

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";
Enter fullscreen mode Exit fullscreen mode

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: () => {}
});
Enter fullscreen mode Exit fullscreen mode

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;
});
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen 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";
});
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen 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 theme on the html and set the new one
  document.documentElement.dataset.theme = theme;
}, [theme]);
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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

const { theme, mode, setMode } = React.useContext(ThemeContext);
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (2)

Collapse
 
ajithjojo profile image
Ajith jojo

nice tut buddy :) keep it up

Collapse
 
ayc0 profile image
Ayc0

Thanks!