Light/Dark Mode Toggle Using MUI and Recoil (TS)
Tags: react, typescript, tutorial, javascript
Published: 2022-04-08 16:52:35

## 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](https://mui.com/) and [Recoil](https://recoiljs.org/), 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](https://reactjs.org/) 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](https://media.giphy.com/media/F1ZR9NJoNMmLWynn2M/giphy.gif)
### 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)
```shell
npx create-react-app light-dark-toggle --template typescript
```

Now install [Recoil](https://recoiljs.org/)

_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._
```shell
npm install recoil
```

:warning: If you do **NOT** want [MUI](https://mui.com/) in your project, skip the section below, but warning that parts of this guide will be incompatible depending on your theme framework. :warning:

Now last thing we need is to install MUI, [emotion](https://emotion.sh/docs/introduction), [Roboto Font](https://www.npmjs.com/package/@fontsource/roboto), and MUI SVG Icons
```shell
npm install @mui/material @emotion/react @emotion/styled @fontsource/roboto @mui/icons-material
```

### Setting It All Up
To set up Recoil we simply need to wrap our app with a `RecoilRoot` component.
```tsx
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;
```

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`.

```tsx
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();
```

You also want to include this option within the `tsconfig.json` file:
```
"jsxImportSource": "@emotion/react",
```

---

## Creating The Atom :microscope:

![Hmm Science](https://media.giphy.com/media/l3q2PnS0nRea63i6Y/giphy.gif)
Recoil is a state management library, and the data store object is represented as an [atom](https://recoiljs.org/docs/basic-tutorial/atoms). 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.
```ts
import { atom } from 'recoil';

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

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

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](https://recoiljs.org/docs/guides/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.
```ts
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')],
});

```

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

---

## Creating A Theme Provider
:warning: 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. :warning:

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.
```tsx
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;
```

---

## 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.
```tsx
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;

```

### 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:
```CSS
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);
  }
}
```

---

## 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.
```tsx
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;
```

---

## Let's See It Already
Assuming I explained it clearly, and you got it all put together in the right places, you can run:
```shell
npm run start
```

Drumroll...... :drum:


![Light Mode Toggle](https://thumbs.gfycat.com/AggressiveCorruptBlueshark-max-1mb.gif)

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](https://media.giphy.com/media/fdyZ3qI0GVZC0/giphy.gif)

Thanks for reading!