DEV Community

Cover image for Implementing Dark Mode in React
Yash Ghodekar
Yash Ghodekar

Posted on

Implementing Dark Mode in React

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.

const preferColorSchemeQuery = "(prefers-color-scheme: dark)";
const theme = matchMedia(preferColorSchemeQuery).matches ? "dark" : "light";
Enter fullscreen mode Exit fullscreen mode

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.

localStorage.setItem("theme", "dark"); // or "light"
localStorage.getItem("theme");
Enter fullscreen mode Exit fullscreen mode

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

const preferColorSchemeQuery = "(prefers-color-scheme: dark)";
const theme = localStorage.getItem("theme") || 
    (matchMedia(preferColorSchemeQuery).matches ? "dark" : "light");
Enter fullscreen mode Exit fullscreen mode

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.

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

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.

āš ļø Warning
Prefer the standardĀ useEffect when possible to avoid blocking visual updates.

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.

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

Our application code now will look something like this:

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

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.

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

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.

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

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

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

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

You can refer to the Codesandbox below:

Please feel free to share your feedback in the comments section. You can connect with me on Twitter or LinkedIn.

Happy hacking! Keep learning! šŸ˜Ž

References

Top comments (2)

Collapse
 
deshawald14 profile image
Digambar

i had no idea about implementing dark mode. really thanks for this detailed explanation loved it.

Collapse
 
vaibhav18matere profile image
VaibhavMatere

very detailed explanation, good work!