Dark theme in React with Redux-toolkit

This article is a continuation of the article Dark theme in React using css variables in scss. If last time we added a dark theme through the native React context, now we will try to do the same thing, but with the help of Reduxmore precisely redux-toolkit

road map

We will follow almost the same steps as last time:

  1. Run-up Let’s create create-react-app project and fix the structure a little

  2. Redux Let’s add a theme switcher component with redux-состоянием

  3. CSS Variables Let’s declare variables for each theme that will affect the styles of the components

  4. Bonus Let’s add routing

1. Preparation

  1. Via create-react-app create a project and immediately add sass And classnames for convenience of work with styles

> npx create-react-app with-redux-theme --template redux
> cd with-redux-theme
> npm i sass classnames -S
  1. Since we will perform all further actions while in the folder /srcthen for convenience we pass to it

> cd src
  1. Delete unnecessary files

# находимся внутри папки /src
> rm -rf app features App.css App.js App.test.js index.css logo.svg

4. Let’s create a convenient application structure

# находимся внутри папки /src
> mkdir -p components/Theme
> touch index.scss root.js store.js
> touch components/Theme/{index.js,index.module.scss,slice.js}

Project subtree inside a folder /src it should turn out like this

# перейдем в корень и проверим структуру
> tree src
src
├── components
│   └── Theme
│       ├── index.js
│       ├── index.module.scss
│       └── slice.js
├── index.js
├── index.scss
├── root.js
├── store.js
└── ...

Now we will write the code.

Since we have made changes to the structure, we will rewrite our src/index.js

// src/index.js
import React from 'react'
import ReactDOM from 'react-dom/client'
import { Provider } from 'react-redux'

import Root from './root'
import store from './store'

import './index.scss'

const rootElement = document.getElementById('root')
if (!rootElement) throw new Error('Failed to find the root element')
const root = ReactDOM.createRoot(rootElement)

root.render(
  <React.StrictMode>
    <Provider store={store}>
      <Root />
    </Provider>
  </React.StrictMode>
)

Instead of App.js i am using file root.js with component Rootwhich in the end we will store routes to pages, but for now …

// src/root.js
const Root = () => (
	<div>There are will be routes</div>
)

export default Root

Now you can proceed to the second part – writing the actual theme change logic

2. Add logic for the theme

Let’s configure our store. In it we will have only one theme reducer. We do it by analogy with counterwhich came out of the box, only ours will be simpler.

// src/store.js
import { configureStore } from '@reduxjs/toolkit'
import themeReducer from './components/theme/slice'

export const store = configureStore({
  reducer: {
    theme: themeReducer,
  },
})

Now we will implement the reducer itself with the rest of the logic necessary for the theme to work.

// src/components/theme/slice.js
import { createSlice } from '@reduxjs/toolkit'

// пытаемся получить тему из локального хранилища браузера
// если там ничего нет, то пробуем получить тему из настроек системы
// если и настроек нет, то используем темную тему
const getTheme = () => {
  const theme = `${window?.localStorage?.getItem('theme')}`
  if ([ 'light', 'dark' ].includes(theme)) return theme

  const userMedia = window.matchMedia('(prefers-color-scheme: light)')
  if (userMedia.matches) return 'light'

  return 'dark'
}

const initialState = getTheme()

export const themeSlice = createSlice({
  name: 'theme',
  initialState,
  reducers: {
    set: (state, action) => action.payload,
  },
})

export const { set } = themeSlice.actions

export default themeSlice.reducer

At this stage, everything works for us, but there is no component that would change the theme.
Let’s implement it:

// src/components/theme/index.js
import React from 'react'
import { useSelector, useDispatch } from 'react-redux'
import cn from 'classnames'

import { set } from './slice'
import styles from './index.module.scss'

const Theme = ({ className }) => {
  const theme = useSelector((state) => state.theme)
  const dispatch = useDispatch()

  React.useEffect(() => {
    document.documentElement.dataset.theme = theme
    localStorage.setItem('theme', theme)
  }, [ theme ])

  const handleChange = () => {
    const next = theme === 'dak' ? 'light' : 'dark'
    dispatch(set(next))
  }

  return (
    <div
      className={cn(
    		className,
    		styles.root,
    		theme === 'dark' ? styles.dark : styles.light)}
      onClick={handleChange}
    />
  )
}

export default Theme
And add styles to the src/components/theme/index.module.scss file
// src/components/theme/index.module.scss
.root {
	position: relative;
  border-radius: 50%;
  display: block;
  height: 24px;
  overflow: hidden;
  width: 24px;
  transition: 0.5s all ease;
  input {
    display: none;
  }
  &:hover {
    cursor: pointer;
  }
  &:before {
    content: "";
    display: block;
    position: absolute;
  }
  &.light:before {
    animation-duration: 0.5s;
    animation-name: sun;
    background-color: var(--text-color);
    border-radius: 50%;
    box-shadow: 10px 0 0 -3.5px var(--text-color),
      -10px 0 0 -3.5px var(--text-color),
      0 -10px 0 -3.5px var(--text-color),
      0 10px 0 -3.5px var(--text-color),
      7px -7px 0 -3.5px var(--text-color),
      7px 7px 0 -3.5px var(--text-color),
      -7px 7px 0 -3.5px var(--text-color),
      -7px -7px 0 -3.5px var(--text-color);
    height: 10px;
    left: 7px;
    top: 7px;
    width: 10px;
    &:hover {
      background-color: var(--background-color);
      box-shadow: 10px 0 0 -3.5px var(--background-color),
                  -10px 0 0 -3.5px var(--background-color),
                  0 -10px 0 -3.5px var(--background-color),
                  0 10px 0 -3.5px var(--background-color),
                  7px -7px 0 -3.5px var(--background-color),
                  7px 7px 0 -3.5px var(--background-color),
                  -7px 7px 0 -3.5px var(--background-color),
                  -7px -7px 0 -3.5px var(--background-color);
    }
  }
  &.dark {
    &:before {
      animation-duration: .5s;
      animation-name: moon;
      background-color: var(--text-color);
      border-radius: 50%;
      height: 20px;
      left: 2px;
      top: 2px;
      width: 20px;
      z-index: 1;
      &:hover {
        background-color: var(--background-color);
      }
    }
    &:after {
      animation-duration: .5s;
      animation-name: moon-shadow;
      background: var(--background-color);
      border-radius: 50%;
      content: "";
      display: block;
      height: 18px;
      position: absolute;
      right: -2px;
      top: -2px;
      width: 18px;
      z-index: 2;
    }
  }
}

@keyframes sun {
  from {
    background-color: var(--background-color);
    box-shadow: 0 0 0 -5px var(--background-color),
                0 0 0 -5px var(--background-color),
                0 0 0 -5px var(--background-color),
                0 0 0 -5px var(--background-color),
                0 0 0 -5px var(--background-color),
                0 0 0 -5px var(--background-color),
                0 0 0 -5px var(--background-color),
                0 0 0 -5px var(--background-color);
  }
  to {
    background-color: var(--text-color);
    box-shadow: 10px 0 0 -3.5px var(--text-color),
                -10px 0 0 -3.5px var(--text-color),
                0 -10px 0 -3.5px var(--text-color),
                0 10px 0 -3.5px var(--text-color),
                7px -7px 0 -3.5px var(--text-color),
                7px 7px 0 -3.5px var(--text-color),
                -7px 7px 0 -3.5px var(--text-color),
                -7px -7px 0 -3.5px var(--text-color);
  }
}

@keyframes moon {
  from {
    height: 0;
    left: 12px;
    top: 12px;
    width: 0;
  }
  to {
    height: 20px;
    left: 2px;
    top: 2px;
    width: 20px;
  }
}

@keyframes moon-shadow {
  from {
    background-color: var(--background-color);
    height: 0;
    right: 7px;
    top: 7px;
    width: 0;
  }
  to {
    background-color: var(--background-color);
    height: 18px;
    right: -2px;
    top: -2px;
    width: 18px;
  }
}

Let’s add our component Theme to Home Page.

// src/root.js
import Theme from './components/Theme'

const Root = () => (
  <>
    <h1>Тёмная тема в React с помощью Redux-toolkit</h1>
	  <Theme />
  </>
)

export default Root

And in order for everything to work as it should, you need to set variables for each topic. We will ask them through css variables, since those variables that are used in scss will not suit us. scss compiles to css rather stupid, it just substitutes the values ​​of the variables in all the places where they appear.

// src/index.scss
:root[data-theme="light"] {
  --background-color: #ffffff;
  --text-color: #1C1E21;
}

:root[data-theme="dark"] {
  --background-color: #18191a;
  --text-color: #f5f6f7;
}

body {
  background: var(--background-color);
  color: var(--text-color);
}

Hooray! Everything works! And now the promised bonus – adding routes

Adding Routing

Let’s install the library first.

> npm i react-router-dom -S

Let’s wrap everything in a provider BrowserRouter from react-router

import React from 'react'
import ReactDOM from 'react-dom/client'
import { Provider } from 'react-redux'
import { BrowserRouter } from 'react-router-dom'

import * as serviceWorker from './serviceWorker'
import Root from './root'
import store from './store'

import './index.scss'

const rootElement = document.getElementById('root')
if (!rootElement) throw new Error('Failed to find the root element')
const root = ReactDOM.createRoot(rootElement)

root.render(
  <React.StrictMode>
    <BrowserRouter>
      <Provider store={store}>
        <Root />
      </Provider>
    </BrowserRouter>
  </React.StrictMode>
)

serviceWorker.unregister()

Now you can in the file src/root.js add code like this

import { Routes, Route } from 'react-router-dom'

import Layout from './components/Layout'
import Home from './pages/Home'
import NoMatch from './pages/NoMatch'

const Root () => (
  <Routes>
    <Route path="/" element={<Layout />}>
      <Route index element={<Home />} />
      <Route path="*" element={<NoMatch />} />
    </Route>
  </Routes>
)

export default Root

Create the missing components

> mkdir -p src/pages/{Home,NoMatch} src/components/Layout
> touch src/pages/Home/index.js src/pages/NoMatch/index.js
> touch src/components/Layout/index.js

Application pages I placed in a folder /pages. Done in a similar way NextJS and I think it’s good practice.

// src/components/Layout/index.js
import { Outlet } from 'react-router-dom'

import Theme from '../Theme'

const Layout = () => (
  <>
    <Theme />
    <main>
      <Outlet />
    </main>
  </>
)

export default Layout
// src/pages/Home/index.js
const Home = () => <h1>Home</h1>

export default Home
// src/pages/NoMatch/index.js
import { Link } from 'react-router-dom'

const NoMatch = () => (
  <>
    <h1>Page Not Found</h1>
    <h2>We could not find what you were looking for.</h2>
    <p>
      <Link to="/">Go to the home page</Link>
    </p>
  </>
)

export default NoMatch

And now we are by default on the page Homeand if we pass to any anotherthen we will open the page NoMatch

Conclusion

Via redux-toolkit adding a dark theme looks even easier. In addition, if you are going to use it on your project anyway, then this approach will be preferable to the context. Share your thoughts in the comments on how you can improve this code or ask questions if something is not clear – I will be happy to answer everyone!

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *