Эта статья является продолжением статьи Тёмная тема в React с использованием css переменных в scss. Если в прошлый раз мы добавляли тёмную тему через родной реактовский контекст, то сейчас мы попробуем сделать всё то же самое, но с помощью Redux, точнее redux-toolkit

Roadmap

Мы проделаем почти те же шаги, что и в прошлый раз:

  1. Run-up Создадим create-react-app проект и немного поправим структуру

  2. Redux Добавим компонент переключателя темы с redux-состоянием

  3. CSS Variables Объявим переменные для каждой темы, которые будут влиять на стили компонентов

  4. Bonus Добавим роутинг

1. Подготовка

  1. С помощью create-react-app создаём проект и сразу добавляем sass и classnames для удобства работы со стилями

> npx create-react-app with-redux-theme --template redux
> cd with-redux-theme
> npm i sass classnames -S
  1. Поскольку все дальнейшие действия мы будем производить, находясь в папке /src, то для удобства перейдем в неё

> cd src
  1. Удалим ненужные файлы

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

4. Создадим удобную структуру приложения

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

Поддерево проекта внутри папки /src должно получиться таким

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

Теперь будем писать код.

Поскольку мы внесли изменения в структуру, то перепишем наш 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>
)

Вместо App.js я использую файл root.js с компонентом Root, который в конце концов у нас будет хранить роуты на страницы, но а пока...

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

export default Root

Теперь можно приступить ко второй части - написание самой логики изменения темы

2. Добавляем логику для темы

Сконфигурируем наш стор. В нем у нас будет один лишь редьюсер темы. Делаем его по аналогии с counter, который шел из коробки, только наш будет попроще.

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

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

Теперь реализуем сам редьюсер с остальной логикой, необходимой для работы темы.

// 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

На этом этапе у нас всё работает, но нет компонента, который бы изменял тему.
Реализуем его:

// 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
И добавим стили в файл src/components/theme/index.module.scss
// 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;
  }
}

Добавим наш компонент Theme на главную страницу.

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

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

export default Root

И чтобы все заработало как надо, нужно задать переменные для каждой темы. Задавать мы их будем через css переменные, поскольку те переменные, которые используются в scss нам не подойдут. scss компилится в css довольно глупо, он просто подставляет значения переменных во всех местах, где они фигурируют.

// 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);
}

Ура! Все работает! И теперь обещанный бонус - добавление роутов

Добавляем роутинг

Для начала установим библиотеку

> npm i react-router-dom -S

Обернем все в провайдер BrowserRouter от 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()

Теперь можно в файле src/root.js добавить такой код

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

Создадим недостающие компоненты

> 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

Страницы приложения я поместил в папку /pages. Подобным образом сделано в NextJS и мне кажется это хорошей практикой.

// 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

И теперь мы по умолчанию находимся на странице Home, а если перейдем на любую другую, то нам откроется страница NoMatch

Заключение

С помощью redux-toolkit добавление тёмной темы выглядит еще проще. К тому же, если вы всё равно собираетесь его использовать на своем проекте, то этот подход будет предпочтительней контекста. Делитесь в комментариях мыслями о том, как можно улучшить этот код или задавайте вопросы, если что-то осталось не ясно - с удовольствием всем отвечу!

Комментарии (3)


  1. yanranevskiy
    07.04.2022 13:02

    Подскажите, пожалуйста, есть ли с таким же оформлением тема для vs code?


    1. borisyuzhakov Автор
      07.04.2022 17:32

      Не совсем понял, в чем вопрос. Если вопрос не относится непосредственно к теме статьи, то пишите лучше в личку.
      В vs code я, лично, пользуюсь Dark+ темой


  1. yavoloh
    07.04.2022 22:18

    Спасибо за статью. Смутил факт, что для использования нужно обязательно монтировать компонент. Интересно увидеть вариант сайд эффектов в subscribe, middleware redux'a или подобном, а не в эффекте переключателя.