How to Add Dark Mode to React with Context and Hooks

How to Add Dark Mode to React with Context and Hooks

More and more, we are seeing the dark mode feature in the apps that we are using every day. From mobile to web apps, the dark mode has become necessary for companies that want to take care of their user's eyes. Indeed, having a bright screen at night is really painful for our eyes. By turning (automatically) the dark mode helps reduce this pain and keep our users engage with our apps all night long (or not).

In this post, we are going to see how we can easily implement a dark mode feature in a ReactJS app. In order to do so, we'll leverage some React features like context, function components, and hooks.

Too busy to read the whole post? Have a look at the CodeSandbox demo to see this feature in action along with the source code.

promo.png

What Will You Learn?

At this end of this post, you will be able to:

  • Combine React Context and the useReducer hook to share a global state throughout the app.
  • Use the ThemeProvider from the styled-components library to provide a theme to all React components within our app.
  • Build a dark mode feature into your React app in an easy and non-intrusive way.

What Will You Build?

In order to add the dark mode feature into our app, we will build the following features:

  • A Switch component to be able to enable or disable the dark mode.
  • A dark and light theme for our styled-components to consume.
  • A global Context and reducer to manage the application state.

Theme Definition

The first thing that we need for our dark mode feature is to define the light and dark themes of our app. In other words, we need to define the colors (text, background, ...) for each theme.

Thanks to the styled-components library we are going to use, we can easily define our themes in a distinct file as JSON objects and provide it to the ThemeProvider later.

Below is the definition of the light and dark themes for our app:

const black = "#363537";
const lightGrey = "#E2E2E2";
const white = "#FAFAFA";

export const light = {
  text: black,
  background: lightGrey
};

export const dark = {
  text: white,
  background: black
};

As you can notice, this is a really simplistic theme definition. It's up to you to define more theme parameters to style the app according to your visual identity.

Now that we have both our dark and light themes, we can focus on how we’re going to provide them to our app.

Theme Provider

By leveraging the React Context API, the styled-components provides us a ThemeProvider wrapper component. Thanks to this component, we can add full theming support to our app. It provides a theme to all React components underneath itself.

Let's add this wrapper component at the top of our React components' tree:

import React from "react";
import { ThemeProvider } from "styled-components";

export default function App() {
  return (
    <ThemeProvider theme={...}>
      ...
    </ThemeProvider>
  );
};

You may have noticed that the ThemeProvider component accepts a theme property. This is an object representing the theme we want to use throughout our app. It will be either the light or dark theme depending on the application state. For now, let's leave it as is as we still need to implement the logic for handling the global app state.

But before implementing this logic, we can add global styles to our app.

Global Styles

Once again, we are going to use the styled-components library to do so. Indeed, it has a helper function named createGlobalStyle that generates a styled React component that handles global styles.

import React from "react";
import { ThemeProvider, createGlobalStyle } from "styled-components";

export const GlobalStyles = createGlobalStyle`...`;

By placing it at the top of our React tree, the styles will be injected into our app when rendered. In addition to that, we'll place it underneath our ThemeProvider wrapper. Hence, we will be able to apply specific theme styles to it. Let's see how to do it.

export const GlobalStyles = createGlobalStyle`
  body, #root {
    background: ${({ theme }) => theme.background};
    color: ${({ theme }) => theme.text};
    display: flex;
    flex-direction: row;
    justify-content: center;
    align-items: center;
    font-family: BlinkMacSystemFont, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
  }
`;

export default function App() {
  return (
    <ThemeProvider theme={...}>
      <>
        <GlobalStyles />
        ...
      </>
    </ThemeProvider>
  );
};

As you can see, the global text and background color are provided by the loaded theme of our app.

It's now time to see how to implement the global state.

promo.png

Global State

In order to share a global state that will be consumed by our components down the React tree, we will use the useReducer hook and the React Context API.

As stated by the ReactJS documentation, Context is the perfect fit to share the application state of our app between components.

Context provides a way to pass data through the component tree without having to pass props down manually at every level.

And the useReducer hook is a great choice to handle our application state that will hold the current theme (light or dark) to use throughout our app.

This hook accepts a reducer and returns the current state paired with a dispatch method. The reducer is a function of type (state, action) => newState that manage our state. It is responsible to update the state depending on the type of action that has been triggered. In our example, we will define only one type of action called TOGGLE_DARK_MODE that will enable or disable the dark mode.

Let's create this reducer function in a separate file, reducer.js:

const reducer = (state = {}, action) => {
  switch (action.type) {
    case "TOGGLE_DARK_MODE":
      return {
        isDark: !state.isDark
      };
    default:
      return state;
  }
};

export default reducer;

As you may have noticed, our state is holding a single boolean variable isDark. If the TOGGLE_DARK_MODE action is triggered, the reducer updates the isDark state variable by toggling is value.

Now that we have our reducer implemented we can create our useReducer state and initialize it. By default, we will disable the dark mode.

import React, { useReducer } from "react";
import reducer from "./reducer";

export default function App() {
  const [state, dispatch] = useReducer(reducer, {
    isDark: false
  });

  ...
};

The only missing piece in our global state implementation is the Context. We'll also define it in a distinct file and export it, context.js:

import React from "react";

export default React.createContext(null);

Let's now combine everything together into our app and use our global state to provide the current theme to the ThemeProvider component.

import React, { useReducer } from "react";
import { ThemeProvider, createGlobalStyle } from "styled-components";
import { light, dark } from "./themes";
import Context from "./context";
import reducer from "./reducer";

...

export default function App() {
  const [state, dispatch] = useReducer(reducer, {
    isDark: false
  });

  return (
    <Context.Provider value={{ state, dispatch }}>
      <ThemeProvider theme={state.isDark ? dark : light}>
        <>
          <GlobalStyles />
          ...
        </>
      </ThemeProvider>
    </Context.Provider>
  );
};

As you can see the Context is providing, through its Provider, the current application state and the dispatch method that will be used by other components to trigger the TOGGLE_DARK_MODE action.

The Switch Component

Well done 👏👏 on completing all the steps so far. We are almost done. We’ve implemented all the logic and components needed for enabling the dark mode feature. Now it’s time to trigger it in our app.

To do so, we'll build a Switch component to allow users to enable/disable dark mode. Here's the component itself:

import React from "react";
import Context from "./context";
import styled from "styled-components";

const Container = styled.label`
  position: relative;
  display: inline-block;
  width: 60px;
  height: 34px;
  margin-right: 15px;
`;

const Slider = styled.span`
  position: absolute;
  top: 0;
  display: block;
  cursor: pointer;
  width: 100%;
  height: 100%;
  background-color: #ccc;
  border-radius: 34px;
  -webkit-transition: 0.4s;
  transition: 0.4s;

  &::before {
    position: absolute;
    content: "";
    height: 26px;
    width: 26px;
    margin: 4px;
    background-color: white;
    border-radius: 50%;
    -webkit-transition: 0.4s;
    transition: 0.4s;
  }
`;

const Input = styled.input`
  opacity: 0;
  width: 0;
  height: 0;
  margin: 0;

  &:checked + ${Slider} {
    background-color: #2196f3;
  }

  &:checked + ${Slider}::before {
    -webkit-transform: translateX(26px);
    -ms-transform: translateX(26px);
    transform: translateX(26px);
  }

  &:focus + ${Slider} {
    box-shadow: 0 0 1px #2196f3;
  }
`;

const Switch = () => {
const { dispatch } = useContext(Context);

  const handleOnClick = () => {
    // Dispatch action
    dispatch({ type: "TOGGLE_DARK_MODE" });
  };

  return (
    <Container>
      <Input type="checkbox" onClick={handleOnClick} />
      <Slider />
    </Container>
  );
};

export default Switch;

Inside the Switch component, we are using the dispatch method from the Context to toggle the dark mode theme.

Finally, let's add it to the app.

export default function App() {

  ...

  return (
    <Context.Provider value={{ state, dispatch }}>
      <ThemeProvider theme={state.isDark ? dark : light}>
        <>
          <GlobalStyles />
          <Switch />
        </>
      </ThemeProvider>
    </Context.Provider>
  );
};

Conclusion

Dark mode has been a highly requested feature, and we successfully added support for it in our React application by using some of the latest React features. I hope this post will help you add dark mode capability to your app and save the eyes of your users.

promo.png