React Dark Mode with Styled Theming and Context

Ross Bulat
May 26 · 11 min read

Bring dark mode to your React apps

Dark mode is becoming more commonly supported in apps, both on the web and natively. React is in a great position to support such a feature, by leveraging the framework’s capabilities and packages readily available in the surrounding ecosystem. Toggling between a dark and light mode can be achieved very elegantly and modularly, as will be demonstrated here.

This article will walk through the entire process of setting up a dark mode for your app, and a means to toggle between a light and dark theme. The demo I designed for this talk is freely available on Github to aid in the reader’s understanding.

To achieve a dark mode and toggle function, the following tools will be used:

  • React’s Context API. We will be defining a context specifically for managing theme toggling, that will be accessible anywhere in your app
  • The useContext hook will be used specifically for getting our theme toggle() function from the above context. useContext gets the value of a provided context from anywhere in your component tree
  • The useState hook to persist what mode the app is currently set to: either light or dark
  • styled-components and styled-theming packages. Styled components allow us to write CSS directly within a component, and with that come a range of benefits for managing styles throughout your app. The latter package, Styled Theming, builds on top of Styled Component’s ThemeProvider context, and provides some handy tools for managing themes that can scale

Note: If you have not yet delved into these packages, I wrote an introduction article to get fellow developers up to speed.

Setting Up the Project

To set up the project on your machine, either clone the Github repository or generate a new Create React App project with the required packages:

// clone github repositorygit clone https://github.com/rossbulat/react-theming-dark-mode.git
cd react-theming-dark-mode
yarn
yarn start
// or create a project from scratchnpx create-react-app react-theming-dark-mode
cd react-theming-dark-mode
yarn
yarn add styled-components, styled-theming
yarn start

With the project initiated we are ready to delve into our theming solution.

Recapping <ThemeProvider />

An elegant theming solution must be scalable, and the configuration of which must be accessible throughout your entire app — this is where React’s Context API comes into play, allowing us to access a context value from anywhere in our component tree. A React theming solution therefore is heavily reliant on Context.

styled-components actually provides us with a context provider specifically for theming, in the form of <ThemeProvider />. To use this component, we simply have to wrap it around the <App /> component, or your root component:

// giving our App theming contextimport { ThemeProvider } from 'styled-components';...<ThemeProvider theme={{ mode: 'light' }}>
<App />
</ThemeProvider>

With styled-theming, instead of passing theme properties into the theme prop, such as a backgroundColor or textColor, we instead define the theme we are working with. In this case we have a mode theme, but you are not limited to just a one-dimensional theme, as my previous article delves into.

From here, any component within <ThemeProvider/> can now access the context’s theme prop. styled-theming makes this super simple with the theme() utility function, where we can define styles for each component based on mode.

The following demonstrates how this looks with a component:

// SomeComponent.jsimport styled from 'styled-components';
import theme from 'styled-theming';
function SomeComponent() { // defining theme properties based on `mode` const textColor = theme('mode', {
light: 'black',
dark: 'white'
});
// using those properties in our component const Wrapper = styled.div`
color: ${textColor}
`;
return (
<Wrapper>
Text will be black in light mode, or white in dark mode.
</Wrapper>
);
}

As well as the theme() utility, we can also get the context value using styled-component’s higher-order component, withTheme(). withTheme() provides the wrapped component with a theme prop, being the value we defined in our context:

import { withTheme } from 'styled-components';function App(props) {   // this will output { mode: 'light' }
console.log(props.theme);
return (
...
);
}
export default withTheme(App);

Beyond style properties, other assets will most likely be based on your theme, such as artwork or text labels. E.g. a theme toggle button maybe display “Switch to Dark Mode” on light mode, and “Switch to Light Mode” on dark mode. Therefore it is critical to also obtain our theme configuration from within our functions, as props. withTheme() allows us to do exactly this.

Now, with that brief refresher on ThemeProvider, let’s explore how we can expand on this concept and add another context for toggling our theme.

Defining our own Theme Context

<ThemeProvider /> is great; it provides a solution of giving our entire app our theme configuration without having to even think about context. However, we still need a means to be able to switch from light to dark, and vice-versa.

Theme toggling needs context

It would be very useful, arguably crucial, to have the ability to toggle a theme from anywhere in the app. Perhaps there will be a switch in your app settings, another one at the top of the home page, or perhaps in a modal — heck, even in an automated demo showcasing what your app is capable of.

The bottom line is that our theme’s toggle() function must be accessible anywhere in the app. In order for this to be possible, we need to create its own context.

Why not just use <ThemeProvider />?

You may be wondering at this point why we do not embed a toggle() function inside our theme prop of <ThemeProvider />, after all, it would provide all child components access to the function.

Doing so would break the conventions of styled-theming, that expects strings to be passed into the theme() utility function. It would also be confusing for other developers to embed functions around our theme configuration. For this reason, I have opted to keep the functions and theme configuration in separate contexts.

What implications does this have on our app? Well, we just need to introduce another context for our toggle() function:

// wrapping ThemeProvider in another context<ThemeToggleContext.Provider value={...}>
^
new context to handle theme toggling
<ThemeProvider theme={...}>
<App />
</ThemeProvider>
</ThemeToggleContext.Provider>

We now have another context, <ThemeToggleContext />, that wraps around <ThemeProvider />.

In reality though, this looks a bit messy — what would be much neater is if we could actually combine these two contexts into a unified component, that handles all our context logic — and wrap that component around <App />. This is exactly what we will do.

Concretely, we can combine both these contexts in another component, and wrap that component around <App />.

This would make our code much more readable, and manageable when it comes to expanding or amending contexts. Let’s create a new file named ThemeContext.js, and from it export a component, MyThemeProvider, that will handle contexts for both our theming configuration and our theme toggling function.

Note: ThemeContext.js is available to browse through here on Github.

Define global style properties

Before implementing ThemeContext.js, we will want to define some global style properties based on mode — either light or dark. It is good practice to do this in a separate file.

I have created a file, theme.js, specifically for doing this:

// src/theme.jsimport theme from 'styled-theming';export const backgroundColor = theme('mode', {
light: '#fafafa',
dark: '#222'
});
export const textColor = theme('mode', {
light: '#000',
dark: '#fff'
});

We have two properties, backgroundColor and textColor, that can now be imported into any component under our theme context, and the correct colours will be provided based on the mode theme value.

Extend this file in any way you see fit for your app.

Implementing ThemeContext.js

Let’s go through an easily-replicable process of creating our theme contexts. Our end goals are:

  • Define ThemeToggleContext, that will store a toggle() function
  • Define an exportable useContext object, allowing us to getThemeToggleContext’s value from any other component, just by importing this object.

Note: useContext is a built-in React hook that gets a value of a context. It is used with the following syntax:

const contextValue = () => React.useContext(MyContext);

We will essentially be using this hook for our ThemeToggleContext, and making it exportable to be used within any component as a quick means of obtaining the context value — aka, our toggle() function.

  • Define an exportable component, MyThemeProvider, that will include both contexts to wrap around <App />.

Defining the Toggle Context

Straight away, we can import the necessary components into ThemeContext.js, and define our second context for toggle(). This context is defined as ThemeToggleContext:

// ThemeContext.jsimport React from "react";
import styled, { ThemeProvider } from 'styled-components';
import { backgroundColor, textColor } from './theme';

// define our toggle context, with a default empty toggle function
const ThemeToggleContext = React.createContext({
toggle: () => {}
});
// define exportable useContext hook objectexport const useTheme =
() => React.useContext(ThemeToggleContext);

// define MyThemeProvider
export const MyThemeProvider = ({ children }) => {
...
}

Before delving into the implementation of MyThemeProvider, let’s review what was defined here.

ThemeToggleContext has been defined — the context object that will handle our theme management functions. It has been given a default value of an empty toggle() function.

A note on default context values

Does it matter that we have not defined our toggle() function from as the default value? No — because our ThemeToggleContext is the top most component in our tree, and therefore there will be no parent components needing that default value.

The React docs sum up the default value behaviour in one sentence: The defaultValue argument is only used when a component does not have a matching [context] Provider above it in the tree.

Furthermore, we have not planned to use this default value. It can be treated more like a signature function, so developers understand what the context is intended to consist of.

Alternatively, it can be left blank, simply being defined as React.createContext();. The context value will actually be defined in the JSX of MyThemeProvider, which we will explore next.

Exporting a useContext object

The second definition of ThemeContext.js is useTheme. useTheme is just a useContext hook, that can be imported into any component to obtain the value of the given context.

Now, within any component wrapped by the context provider, we can import useTheme and obtain the context value:

// DeepNestedComponent.jsimport { useTheme } from './ThemeContext';function DeepNestedComponent () {   // get the context value
const themeToggle = useTheme();
// we now have access to toggle()
return (
<a onClick={() => themeToggle.toggle()}>
Toggle Mode!
</a>
);
}

But in order for useTheme to work, we need to implement our MyThemeProvider component.

Combining the two contexts in MyThemeProvider

As mentioned earlier, the MyThemeProvider needs to combine both contexts in order for our full theming solution to work. Not only this, it can also include some initial page styling via a Wrapper styled component too.

This is what the implementation looks like:

// ThemeContext.js...export const MyThemeProvider = ({ children }) => {   // Wrapper providing some page styling based on theme
const Wrapper = styled.div`
background-color: ${backgroundColor};
color: ${textColor};
`;
// define toggle function
const toggle = () => {
console.log('toggle coming next');
};
// render both contexts, then Wrapper, then children
return (
<ThemeToggleContext.Provider
value={{ toggle: toggle }}
>
<ThemeProvider
theme={{
mode: 'light'
}}
>
<Wrapper>
{children}
</Wrapper>
</ThemeProvider>
</ThemeToggleContext.Provider>
);
};
export default ThemeProvider;

The majority of the function is now developed, however, we are still missing the toggle() function implementation, and our <ThemeProvider/> mode is still hard-coded as light, and therefore it cannot be updated.

To alleviate these bottlenecks, the last piece of the puzzle is state.

Introducing state to manage theme mode

State needs to be introduced to manage the actual theme mode: light or dark, that will trigger a re-render upon the value changing. The useState hook can be used for this.

The hook can be added above toggle() with a default mode of light. Furthermore, toggle() can now be fully implemented:

...// default mode is set to `light`const [themeState, setThemeState] = React.useState({
mode: 'light'
});
// toggle() now switches `mode` between light and dark, and updates themeStateconst toggle = () => {
const mode = (themeState.mode === 'light'
? `dark`
: `light`);

setThemeState({ mode: mode });
};

We can now replace the <ThemeProvider /> value with whatever our themeState value is set to:

...
<ThemeProvider
theme={{
mode: themeState.mode
}}
>
...
</ThemeProvider>
...

Updating themeState via toggle() will now update our <ThemeProvider />, leading our entire app to update its theme.

Finally, inside index.js, we can import MyThemeProvider to be wrapped around <App />:

// wrapping both contexts from one component, in index.js...
import { MyThemeProvider } from "./ThemeContext";
ReactDOM.render(
<MyThemeProvider>
<App />
</MyThemeProvider>
,document.getElementById('root'));

And that concludes our theming setup! Our contexts are now hooked up and state is in charge of updating our <ThemeProvider />.

Calling toggle() with onClick

The last section in this talk will demonstrate how to call toggle() from a button click inside <App />.

To demonstrate styled-theming to a greater extent, I have defined two more properties in theme.js for button styling. In a light mode, the button will be dark grey to contrast with the white background, with white text. In a dark mode, the button will be a very light grey, with black text:

// theme.js...
export const buttonBackgroundColor = theme('mode', {
light: '#222',
dark: '#eee'
});
export const buttonTextColor = theme('mode', {
light: 'white',
dark: 'black'
});

Now within App.js, we can go ahead and define a button that toggles our theme:

...
import { useTheme } from './ThemeContext';
import styled, { withTheme } from 'styled-components';
import { buttonBackgroundColor, buttonTextColor } from './theme';

function App (props) {
// get toggle context with `useTheme`
const themeToggle = useTheme();

// style a button with theme properties
const Button = styled.button`
background: ${buttonBackgroundColor};
color: ${buttonTextColor};
/* rest of properties snipped */
`;

// `Button` onClick calls themeState.toggle()
return (
<header className="App-header">
<img
src={logo}
className="App-logo"
alt="logo"
/>
<p>
<Button
onClick={() => themeState.toggle()}
>
{ props.theme.mode === 'dark'
? "Switch to Light Mode"
: "Switch to Dark Mode"
}

</Button>
</p>
</header>
);
}
export default withTheme(App);

Notice here that the withTheme() HOC is also being utilised, provided by styled-components, to obtain the theme prop. This is then used to display button text, that depends on the current theme mode.

This button (or multiple buttons) can be embedded anywhere in your app; just import useTheme to get that toggle() function from the context provider.

In Summary

The final project resembles the following result:

This talk has taken you through a multi-context setup of theming your app, and a means to manipulate that theming through a context. To summarise:

  • We have revised the usage of styled-components and styled-theming to be used in conjunction with a dark and light mode for your app.
  • The useState and useContext hooks have been used to persist a theme mode and get the toggling context value respectively.
  • Global theme properties have been defined in a separate file, available to be imported into any other component.
  • The withTheme useContext hook can be imported into any component to fetch our theme management functions.

Additional challenge: localStorage

As an additional challenge, incorporate localStorage to persist your theme changes through page refreshes. localStorage caches data indefinitely, records of which will only be removed if the user deletes website data, or if you programatically remove a localStorage item. Because localStorage items do not have time limits like session data does, it is ideal to save data like a theme mode, that is not often changed nor need to be expired due to security concerns.

Incorporate the following methods to get localStorage working:

  • localStorage.getItem('mode'): Obtain the current theme mode
  • localStorage.setItem('mode', value): Persist a theme mode
  • localStorage.removeItem('mode'): Remove mode from local storage

Once again, the code for this talk is available on Github.

Typescript implementation also available

I have also uploaded a Typescript implementation on Github, and have provided a small explainer video on YouTube.

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade