DEV Community

Cover image for Light/Dark Mode Toggle Using MUI and Recoil (TS)
Bobby Plunkett
Bobby Plunkett

Posted on • Updated on

Light/Dark Mode Toggle Using MUI and Recoil (TS)

What Are We Going To Do Here?

Recently, I needed a good way to toggle between light and dark mode in a project. I also needed it to remember the user's decision when they refresh or leave the site by storing the value within local storage. This is probably not the best way to do this; it is just what I came up with for the task. My project was already using both MUI and Recoil, which is the reasoning behind using these libraries. If you are only using Recoil within your project, then this guide still may be helpful with some tweaks to fit your theme framework.


Getting Started

In the next section, we will create a new React project, install Recoil & MUI, and set everything up.
I will not be installing any other packages than what is required for this guide, such as linting, formatting, etc.

Let's Do This

Install The Dependencies

First, we need a React project, and for this, I will be using the Typescript template. (Ignore if you already have one set up)

npx create-react-app light-dark-toggle --template typescript
Enter fullscreen mode Exit fullscreen mode

Now install Recoil

If you are using any other package manager such as yarn or pnpm, just replace npm with whatever one you use. For simplicity, I will be using NPM for this guide.

npm install recoil
Enter fullscreen mode Exit fullscreen mode

⚠️ If you do NOT want MUI in your project, skip the section below, but warning that parts of this guide will be incompatible depending on your theme framework. ⚠️

Now last thing we need is to install MUI, emotion, Roboto Font, and MUI SVG Icons

npm install @mui/material @emotion/react @emotion/styled @fontsource/roboto @mui/icons-material
Enter fullscreen mode Exit fullscreen mode

Setting It All Up

To set up Recoil we simply need to wrap our app with a RecoilRoot component.

import React from 'react';
import { RecoilRoot } from 'recoil';
import logo from './logo.svg';
import './App.css';

function App() {
  return (
    <RecoilRoot>
      <div className="App">
        <header className="App-header">
          <img src={logo} className="App-logo" alt="logo" />
          <p>
            Edit <code>src/App.tsx</code> and save to reload.
          </p>
          <a
            className="App-link"
            href="https://reactjs.org"
            target="_blank"
            rel="noopener noreferrer"
          >
            Learn React
          </a>
        </header>
      </div>
    </RecoilRoot>
  );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

If you installed MUI, you also need to set up the Roboto font we installed.

If you are in a new React project, head to src/index.tsx. If you did not just create your project, in most cases the same path will still be valid, but if it is not, then find the root of your project, which is usually the file that contains a call to ReactDOM.render.

import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';

/* Import the required sizes of the Roboto font */
import '@fontsource/roboto/300.css';    // 300
import '@fontsource/roboto/400.css';    // 400
import '@fontsource/roboto/500.css';    // 500
import '@fontsource/roboto/700.css';    // 700

ReactDOM.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  document.getElementById('root')
);

// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
Enter fullscreen mode Exit fullscreen mode

You also want to include this option within the tsconfig.json file:

"jsxImportSource": "@emotion/react",
Enter fullscreen mode Exit fullscreen mode

Creating The Atom 🔬

Hmm Science
Recoil is a state management library, and the data store object is represented as an atom. For our use case, we will be storing the current mode within an atom, also leveraging some cool things the library offers to store and retrieve values from local storage.

Defining The Data

Create a new file to store our atom, and name it whatever you like. For this guide, I chose app-atoms.ts. Then create the atom to store our theme mode.

import { atom } from 'recoil';

export type ThemeMode = 'light' | 'dark';

export const appThemeMode = atom<ThemeMode>({
  key: 'AppThemeMode',
  default: 'light',
});
Enter fullscreen mode Exit fullscreen mode

But you're probably asking, "How does this use local storage to store the user's choice?" and that makes sense.

The answer is pretty simple. It doesn't.

But don't fret. This is where another cool Recoil feature makes this job easy. Atom Effects are similar to Reacts useEffect. However, they are triggered by changes within the atom rather than a component. This is useful because this decouples the state outside a single component, avoiding any prop juggling to provide data to child components.

Effects And Local Storage

Since we need to store and retrieve data from local storage, we can use atom effects to pull data on load and update on change.

import { atom, AtomEffect } from 'recoil';

export type ThemeMode = 'light' | 'dark';

/**
 * This is our Atom Effect which will behave similarly to React.useEffect with
 * the atom in the dependencies array
 *
 * @param key the value used to store and retrieve data from local storage
 */
const localStorageEffect =
  (key: string): AtomEffect<ThemeMode> =>
  ({ setSelf, onSet }) => {
    // Retrieve the value stored at the specified key
    const stored = localStorage.getItem(key);
    // Check if the value exists and is light or dark
    if (stored === 'dark' || stored === 'light') {
      // If the value is valid, the call the provided function setSelf which initializes the atom value
      setSelf(stored);
    }
    // Creates the callback triggered when the atom is changed
    onSet((value, _, isReset) => {
      if (isReset) {
        // If atom has been reset then remove it from local storage
        localStorage.removeItem(key);
      } else {
        // If value has changed then store the value in local storage
        localStorage.setItem(key, value || _); // the || is a fail-safe if for any reason value is null the value will revert to default
      }
    });
  };

export const appThemeMode = atom<ThemeMode>({
  key: 'AppThemeMode',
  default: 'light',
  // Now we need to add it to our effects array
  effects: [localStorageEffect('example-theme-mode')],
});

Enter fullscreen mode Exit fullscreen mode

And now as our atom changes, it will store, update, and remove our theme data from local storage as needed.


Creating A Theme Provider

⚠️ The following section will be focused on MUI. If you did not import this package, you would need to improvise to fit it into your framework. ⚠️

MUI provides a great theme system and will be using that for this guide. To keep things a bit more clean and tidy, we will create a new component that will provide this theme system, which I named ThemeProvider.tsx. This component will read the atom, and memoize an MUI Theme object to only update when the atom value changes.

import React, { ReactElement, useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import { createTheme, CssBaseline, ThemeProvider } from '@mui/material';
import { appThemeMode } from './app-atoms';

interface Props {
  children: ReactElement;
}

function AppThemeProvider({ children }: Props): ReactElement {
  const mode = useRecoilValue(appThemeMode);
  const theme = useMemo(
    () =>
      createTheme({
        palette: {
          mode,
          primary: {
            main: '#61dafb',
          },
          secondary: {
            main: '#EB9612CC',
          },
        },
      }),
    [mode]
  );
  return (
    <ThemeProvider theme={theme}>
      <CssBaseline />
      {children}
    </ThemeProvider>
  );
}

export default AppThemeProvider;
Enter fullscreen mode Exit fullscreen mode

Let's Make Mode Toggle Button

We also need to make a button that toggles light/dark mode, this component will change the icon based on the current mode and update it the mode once clicked. This relies on the atom data source we created earlier.

import React, { ReactElement } from 'react';
import { useRecoilState } from 'recoil';
import { IconButton } from '@mui/material';
import LightModeIcon from '@mui/icons-material/LightMode';
import DarkModeIcon from '@mui/icons-material/DarkMode';
import { appThemeMode, ThemeMode } from './app-atoms';

interface DynamicIconProps {
  mode: ThemeMode;
}

function DynamicIcon({ mode }: DynamicIconProps): ReactElement {
  if (mode === 'dark') return <DarkModeIcon />;
  return <LightModeIcon />;
}

function ModeToggleButton(): ReactElement {
  const [mode, setMode] = useRecoilState(appThemeMode);

  const toggleMode = () => {
    setMode((prevState) => (prevState === 'light' ? 'dark' : 'light'));
  };

  return (
    <IconButton onClick={toggleMode} sx={{ width: 40, height: 40 }}>
      <DynamicIcon mode={mode} />
    </IconButton>
  );
}

export default ModeToggleButton;

Enter fullscreen mode Exit fullscreen mode

Also, to make the default project a bit nicer, let's slim down the standard CSS as MUI will replace them.

Open the App.css file and replace the contents with:

HTML,body,#root {
  height: 100%;
}

.App-logo {
  height: 40vmin;
  pointer-events: none;
}

@media (prefers-reduced-motion: no-preference) {
  .App-logo {
    animation: App-logo-spin infinite 20s linear;
  }
}

@keyframes App-logo-spin {
  from {
    transform: rotate(0deg);
  }
  to {
    transform: rotate(360deg);
  }
}
Enter fullscreen mode Exit fullscreen mode

Finally, Putting It All Together

We now have all the pieces we need to get this running, with some last few modification to App.tsx we can finally see
our working mode toggle with persistence.

import React from 'react';
import { RecoilRoot } from 'recoil';
import { Container, Link, Stack, Typography } from '@mui/material';
import AppThemeProvider from './AppThemeProvider';
import ModeToggleButton from './ModeToggleButton';
import logo from './logo.svg';
import './App.css';

function App() {
  return (
    <RecoilRoot>
      <AppThemeProvider>
        <Container sx={{ height: '100%' }}>
          <Stack direction="row" justifyContent="flex-end" sx={{ my: 4 }}>
            <ModeToggleButton />
          </Stack>
          <Stack justifyContent="center" alignItems="center" height="75%">
            <img src={logo} className="App-logo" alt="logo" />
            <Typography>
              Edit <code>src/App.tsx</code> and save to reload.
            </Typography>
            <Link
              className="App-link"
              href="https://reactjs.org"
              target="_blank"
              rel="noopener noreferrer"
              underline="none"
            >
              Learn React
            </Link>
          </Stack>
        </Container>
      </AppThemeProvider>
    </RecoilRoot>
  );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

Let's See It Already

Assuming I explained it clearly, and you got it all put together in the right places, you can run:

npm run start
Enter fullscreen mode Exit fullscreen mode

Drumroll...... 🥁

Light Mode Toggle

It should also remember the last decision you made after refreshing or navigating away from the URL.

Conclusion

As I said before, I'm not claiming this is the best way to approach this task, but this is what worked for my project, and I thought I would share the solution I was able to come up with. I hope someone finds this helpful, and if you have a question, please feel free to ask! If you have any suggestions or comments, please let me know. I'm always looking for other viewpoints and areas to improve.

Congrats

Thanks for reading!

Top comments (0)