Implementing dark mode in React

Ideal solution

The ideal solution for implementing “dark mode” in a React application satisfies the following requirements:

  • The app supports SSR. If the app runs only on client-side, then all solutions work without a problem. Here we use a static/prerendered app with Gatsby.
  • The app remembers the selected mode, so we save it in local storage.
  • The app uses React’s context to implement dark mode. You may already be using something like styled-components themes not only for colors, but for implementing a design system, or for creating component primitives with styled-system and rebass. As a result, you may want the styling to be in one place, and you don’t want to switch between styled-components, CSS, or inline styling.

Spoiler: None of the solutions checks all the boxes above. Let’s take them one by one to see why this happens. We’ll start with the context.

* This is not a step by step guide. Check this GitHub repository that has the full code. I will also give a link with the branch in each section.

Dark mode with context (no local storage)

GitHub repo master branch

In the first solution, we’ll use context, but we won’t save the theme in the local storage. To implement the context, I’m using a blog post by Kent C. Dodds called “How to use react context effectively.” If the code below seems funny to you, please read the post, it’s worth your time. So the plan is to:

  • Create a new context provider that swaps the theme of the styled-components’ ThemeProvider, and stores it in the state.
  • Because we’re using Gatsby, we’ll wrap the context Provider on the root element in gatsby-browser.js and gatsby-ssr.js. Alternatively, we can wrap only a Layout component, but then we won’t be able to use the context in our page components.

Let’s start by creating our context provider. We’ll call it theme-context.jsx, and we’ll place it in src/context. The file looks like this:

// src/context/theme-context.jsx
import React, { useState, useContext, useCallback, createContext } from "react";
import { ThemeProvider as BaseThemeProvider } from "styled-components";

import { lightTheme, darkTheme } from "../themes";

const ThemeContext = createContext();

const ThemeProvider = ({ children }) => {
  const [themeString, setThemeString] = useState("light");
  const themeObject = themeString === "dark" ? darkTheme : lightTheme;
  return (
    <ThemeContext.Provider value={{ themeString, setThemeString }}>
      <BaseThemeProvider theme={themeObject}>{children}</BaseThemeProvider>
    </ThemeContext.Provider>
  );
};

function useTheme() {
  const context = useContext(ThemeContext);
  if (!context) throw new Error("useTheme must be used within a ThemeProvider");
  const { themeString, setThemeString } = context;
  const toggleTheme = useCallback(() => {
    if (themeString === "light") setThemeString("dark");
    else if (themeString === "dark") setThemeString("light");
  }, [themeString]);
  return {
    theme: themeString,
    toggleTheme
  };
}

export { ThemeProvider, useTheme };

In the code above, we create a provider and a hook that uses the context.

In the provider, we save the theme string in the state (light or dark), and we choose the correct theme object inside the render method. Then, we expose the theme string and the setter method through the context value, and we pass the theme object to the styled-components ThemeProvider.

We consume the theme inside the useTheme hook, which exposes the theme string (theme) and the function that toggles it (toggleTheme). We also do some error handling and performance optimizations with useCallback. To use our new theme, we only have to use this hook.

Next, we want to create our theme objects in src/themes/ folder. I said that I want to use them for other things, not only for colors. In this example, let’s use the themes only for colors to make the code simpler:

// src/themes/index.js
const lightTheme = {
  bg: "white",
  bgDark: "pink",
  color: "black",
  accent: "blue"
};

const darkTheme = {
  bg: "#2c1320",
  bgDark: "#15090f",
  color: "white",
  accent: "#ef86a9"
};

export { lightTheme, darkTheme };

Finally, we wrap the Gatsby’s root element in gatsby-browser.js and gatsby-ssr.js. The files are identical; just copy-and-paste the same code:

// gatsby-ssr.js, gatsby-browser.js

import React from "react";
import "normalize.css";
import "typeface-fira-sans";
import "typeface-merriweather";

import { ThemeProvider } from "./src/context/theme-context";
import GlobalStyle from "./src/components/GlobalStyle";

export const wrapRootElement = ({ element }) => (
  <ThemeProvider>
    <>
      <GlobalStyle />
      {element}
    </>
  </ThemeProvider>
);

We also want a Toggle component that will switch between light and dark mode. For this reason, I copied the Toggle component from Dan Abramov’s blog, which is a slightly altered Toggle from react-toggle. In the comments, it says that it has some accessibility improvements. So you can either copy the Toggle.js, Toggle.css, and add the sun/moon images from Dan Abramov’s blog, or just use the default toggle from react-toggle.

We’ll place the toggle somewhere in the header component. We’re also using the theme colors in the “Container” component and the theme hook in the header’s render method:

// src/components/header.js

import React from "react";
import { Link } from "gatsby";
import styled from "styled-components";
import PropTypes from "prop-types";
import Toggle from "./Toggle";

import sun from "../images/sun.png";
import moon from "../images/moon.png";
import { useTheme } from "../context/theme-context";
const Container = styled.header`  color: ${({ theme }) => theme.color};
  background-color: ${({ theme }) => theme.bgDark};
  margin-bottom: 1.45rem;

  a {
    text-decoration: none;
    color: ${({ theme }) => theme.color};
  }
`;

const Header = ({ siteTitle }) => {
  const { theme, toggleTheme } = useTheme();  return (
    <Container>
      <div
        style={{
          margin: `0 auto`,
          maxWidth: 960,
          padding: `1.45rem 1.0875rem`
        }}
      >
        <h1 style={{ margin: 0 }}>
          <Link to="/">{siteTitle}</Link>
        </h1>
        <Toggle          defaultChecked={theme === "dark" ? true : false}          onChange={toggleTheme}          icons={{            checked: (              <img                style={{ pointerEvents: "none" }}                width="16"                height="16"                alt="moon"                aria-hidden                src={moon}              />            ),            unchecked: (              <img                style={{ pointerEvents: "none" }}                width="16"                height="16"                alt="sun"                aria-hidden                src={sun}              />            )          }}        />      </div>
    </Container>
  );
};

Header.propTypes = {
  siteTitle: PropTypes.string
};

Header.defaultProps = {
  siteTitle: ``
};

export default Header;

And this is what the app looks like after the changes:

Dark mode with context

It works fine in both development and production modes, but every time we reload the page, it defaults back to light mode. If your app doesn’t reload much, it might be ok; but you never know how the user will use your app. As a result, this behavior can become annoying really fast. To fix that, we can save the preferred user theme in local storage:

Dark mode with context and local storage

GitHub repo context-local-storage-bug branch

I will use a custom hook to save the theme string in local storage. Alternatively, you can use an NPM package. The useLocalStorage hook works by getting a name string (theme) and the initial value (“light”), and it returns back the value and a function to change it:

// src/hooks/useLocalStorage.js
import { useState, useEffect } from "react";

export const useLocalStorage = (name, initialValue) => {
  const windowGlobal = typeof window !== "undefined" && window;
  const [value, setValue] = useState(() => {
    if (windowGlobal) {
      const currentValue = windowGlobal.localStorage.getItem(name);
      return currentValue ? JSON.parse(currentValue) : initialValue;
    }
    return initialValue;
  });

  useEffect(() => {
    if (windowGlobal)
      windowGlobal.localStorage.setItem(name, JSON.stringify(value));
  }, [name, value, windowGlobal]);
  return [value, setValue];
};

To use this hook in our ThemeProvider, we only have to change 2 lines of code:

// src/context/theme-context.jsx

import React, { useContext, useCallback, createContext } from "react";
import { ThemeProvider as BaseThemeProvider } from "styled-components";

import { useLocalStorage } from "../hooks/useLocalStorage";import { lightTheme, darkTheme } from "../themes";

const ThemeContext = createContext();

const ThemeProvider = ({ children }) => {
  const [themeString, setThemeString] = useLocalStorage("theme", "light");  const themeObject = themeString === "dark" ? darkTheme : lightTheme;
  return (
    <ThemeContext.Provider value={{ themeString, setThemeString }}>
      <BaseThemeProvider theme={themeObject}>{children}</BaseThemeProvider>
    </ThemeContext.Provider>
  );
};

function useTheme() {
  // skipping hook implementation
}

export { ThemeProvider, useTheme };

If you try it in development, it works fine. You can switch the theme by pressing the toggle button, and if you reload the page, the app remembers your preference. But if you build it with gatsby build, and serve it locally with gatsby serve, you’ll see the following:

The bug with context and local storage

If you choose the dark mode, the theme is getting stored correctly in local storage, but if you reload the page you see the light mode. Also, the Toggle component is now broken. It needs some extra clicks to change the theme after reloading the page (if you had dark mode selected). If you log in the console the themes and context, you’ll see that everything is fine; the dark mode is selected as expected. So, why is this happening?

This happens because of how React’s hydrate method treats differences between client and server. During build time, we apply the default light theme, so we add to the DOM elements the styled-components classes for the light theme. When we go in the client and try to hydrate the markup, React sees that we now have a different theme from local storage, so we want to apply different classes for the dark mode. React doesn’t do that for attributes. Only for text mainly because of timestamps differences.

You can see it in action if you create a post context with 3 posts, and store them the same way we store the theme in local storage. After you save them in local storage, you can comment one post out. You’ll see that React will hydrate the text correctly, as it’s shown in the following gif:

GitHub repo posts branch

React hydrates the text correctly

So, what can you do? In this case, you can force an extra render when the component mounts to update the classes. If you do that, this is what the app looks like in production:

Forcing an extra render

GitHub repo context-local-storage (correct)

As you can see it now works, but we momentarily see a flash of unstyled content. This happens because we go quickly from light to dark mode. It’s quite noticeable because the color differences are big. You can tone it down if you add a CSS transition in the items that change color, but you’ll have to do this for every item. Also, if your app takes some time to load, or the users access your app from a slow network, they will be stuck for a while with the incorrect theme until the app becomes interactive.

So it seems that we can’t have a perfect solution if we only use context. In the last method, we’ll run some code before React to manipulate the DOM and change the colors with CSS variables:

Dan Abramov’s solution with CSS

In this section, I will only give a brief overview of this method. If you want the full code, please check the css branch on the GitHub repository

We saw earlier that we can’t rely on context due to the way React’s hydrate method treats differences between client and server. So we’ll have to do something else. We have to run our theme-related JavaScript code before React. In a Gatsby application, we can do that by overriding the default html.js file.

We define a script tag, and inside that script, we declare an IIFE where we do the following:

  • We do the local storage stuff we previously did with the hook in React.
  • We add a class to the body element according to the selected theme (dark, light).
  • We attach to the window object some methods that we’ll use inside our components later when they mount to change the theme.
  • We also perform a matchMedia query for a preferred color scheme that is only available for Safari right now (I never expected to say that). It will be available in Chrome 76 and Firefox 67.
// src/html.js

import React from "react";
import PropTypes from "prop-types";

export default class HTML extends React.Component {
  render() {
    return (
      <html {...this.props.htmlAttributes}>
        <head>
          <meta charSet="utf-8" />
          <meta httpEquiv="x-ua-compatible" content="ie=edge" />
          <meta
            name="viewport"
            content="width=device-width, initial-scale=1, shrink-to-fit=no"
          />
          {this.props.headComponents}
        </head>
        <body {...this.props.bodyAttributes} className="light">
          <script            dangerouslySetInnerHTML={{              __html: `              (function() {                window.__onThemeChange = function() {};                function setTheme(newTheme) {                  window.__theme = newTheme;                  preferredTheme = newTheme;                  document.body.className = newTheme;                  window.__onThemeChange(newTheme);                }                var preferredTheme;                try {                  preferredTheme = localStorage.getItem('theme');                } catch (err) { }                window.__setPreferredTheme = function(newTheme) {                  setTheme(newTheme);                  try {                    localStorage.setItem('theme', newTheme);                  } catch (err) {}                }                var darkQuery = window.matchMedia('(prefers-color-scheme: dark)');                darkQuery.addListener(function(e) {                  window.__setPreferredTheme(e.matches ? 'dark' : 'light')                });                setTheme(preferredTheme || (darkQuery.matches ? 'dark' : 'light'));              })();            `            }}          />          {this.props.preBodyComponents}
          <div
            key={`body`}
            id="___gatsby"
            dangerouslySetInnerHTML={{ __html: this.props.body }}
          />
          {this.props.postBodyComponents}
        </body>
      </html>
    );
  }
}

HTML.propTypes = {
  htmlAttributes: PropTypes.object,
  headComponents: PropTypes.array,
  bodyAttributes: PropTypes.object,
  preBodyComponents: PropTypes.array,
  body: PropTypes.string,
  postBodyComponents: PropTypes.array
};

Next, we go to our header component, that’s where our toggle component is, and use the setTheme methods and our theme. Additionally, in the code below you can see that in the Container component we use CSS variables for the colors instead of getting them from the styled-components theme. Those variables come from our GlobalStyle.jsx component:

// src/components/header.jsx
import React, { useState, useEffect } from "react";
// skipped

const Container = styled.header`
  color: var(--color);  background-color: var(--bgDark);  margin-bottom: 1.45rem;

  a {
    text-decoration: none;
    color: var(--color);  }
`;

const Header = ({ siteTitle }) => {
  const [theme, setTheme] = useState(null);  const ONCE = [];  useEffect(() => {    setTheme(window.__theme);    window.__onThemeChange = () => setTheme(window.__theme);  }, ONCE);  return (
    <Container>
      <div
        style={{
          margin: `0 auto`,
          maxWidth: 960,
          padding: `1.45rem 1.0875rem`
        }}
      >
        <h1 style={{ margin: 0 }}>
          <Link to="/">{siteTitle}</Link>
        </h1>
        <Toggle
          checked={theme === "dark"}          onChange={e =>            window.__setPreferredTheme(e.target.checked ? "dark" : "light")          }          {/* skipped */}
        />
      </div>
    </Container>
  );
};

// skipped
// src/components/GlobalStyle.jsx
import { createGlobalStyle } from "styled-components";

const GlobalStyle = createGlobalStyle`
    // skipped

    a {
        color: var(--accent);
    }

    body {
        background-color: var(--bg);
    }

    body.light {
    --bg: white;
    --bgDark: pink;
    --color: black;
    --accent: blue;
    }

    body.dark {
    -webkit-font-smoothing: antialiased;

    --bg: #2c1320;
    --bgDark: #15090f;
    --color: white;
    --accent: #ef86a9;
    }
`;
export default GlobalStyle;

And if we build our application this how it looks:

The final solution with CSS variables

By the way, in this example, I’m still wrapping the root element with a ThemeProvider from styled-components. I just don’t use it for the colors.

In the following section, you can find the sources I used for this post and some other solutions that are similar to the ones mentioned before. If you have another solution for implementing dark mode, feel free to share it in the comments.

Resources

Other things to read

Popular notes

Other posts