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.
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
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
⚠️ 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
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;
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();
You also want to include this option within the tsconfig.json
file:
"jsxImportSource": "@emotion/react",
Creating The Atom 🔬
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',
});
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')],
});
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;
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;
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);
}
}
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;
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
Drumroll...... 🥁
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.
Thanks for reading!
Top comments (0)