В этой статье напишем кастомный хук на ReactJS, который будет менять тему веб-приложения.
Особенности хука useTheme, который мы реализуем:
- использует CSS переменные
- быстро кастомизируется
- плавный переход между темами
- умеет сохранять выбранную тему в local storage
Код хука useTheme
Сам хук выглядит довольно просто.
// hooks/use-theme.js
import { useLayoutEffect, useState } from 'react'
const isDarkTheme = window?.matchMedia('(prefers-color-scheme: dark)').matches
const defaultTheme = isDarkTheme ? 'dark' : 'light'
export const useTheme = () => {
const [theme, setTheme] = useState(
localStorage.getItem('app-theme') || defaultTheme
)
useLayoutEffect(() => {
document.documentElement.setAttribute('data-theme', theme)
localStorage.setItem('app-theme', theme)
}, [theme])
return { theme, setTheme }
}
prefers-color-scheme
используется для определения выбранной пользователем темы (светлая или темная). Пользователь указывает свои предпочтения через настройку операционной системы или через настройку user agent.
document.documentElement.setAttribute('data-theme', theme)
- этой строчкой мы добавляем кастомный data-атрибут в тег html
. Таким образом, например, при выборе светлой темы в теге html
появится data-атрибут data-theme="light"
.
Добавление CSS переменных
Далее мы должны указать CSS переменные, которые будут иметь различные значение для разных тем. Например, --button-text-color
имеет значение #ffffff
при темной теме и #252525
- при светлой.
/* index.css */
html[data-theme='dark'] {
--button-text-color: #ffffff;
--button-background-color: #4e005c;
--button-border-color: #ba8fc2;
--background-color: #292929;
--icon-color: #ba8fc2;
}
html[data-theme='light'] {
--button-text-color: #252525;
--button-background-color: #f9d4ff;
--button-border-color: #4e005c;
--background-color: #dfdfdf;
--icon-color: #4e005c;
}
Применение CSS переменных
Далее нам нужно применить эти CSS переменные к соответствующим элементам.
Добавление transition
дает плавность при переключении тем.
/* App.css */
.app__container {
background-color: var(--background-color);
transition: background-color 200ms linear;
}
.app__logo {
color: var(--icon-color);
transition: color 500ms linear;
}
.app__button,
.app__button:hover,
.app__button:focus,
.app__button:active,
.app__button:not(:disabled):not(.disabled):active {
color: var(--button-text-color);
background-color: var(--button-background-color);
border-color: var(--button-border-color);
transition: color 500ms linear, background-color 500ms linear,
border-color 500ms linear;
}
Использование хука useTheme
Здесь добавлены две кнопки, каждая из которых отвечает за переключение на светлую или темную тему. Они обернуты в ButtonGroup
.
Элементы логотип, контейнер и кнопка имеют соответствующие CSS классы, которые мы описали выше - app__logo
, app__container
и app__button
. В этих классах были использованы CSS переменные.
import React from 'react'
import Button from 'react-bootstrap/Button'
import ButtonGroup from 'react-bootstrap/ButtonGroup'
import { FaCoffee } from 'react-icons/fa'
import { useTheme } from './hooks/use-theme'
import './App.css'
export default function App() {
const { theme, setTheme } = useTheme()
const handleLightThemeClick = () => {
setTheme('light')
}
const handleDarkThemeClick = () => {
setTheme('dark')
}
return (
<div className="app__container w-100 h-100 d-flex flex-column">
<div className="p-3 d-flex justify-content-end">
<ButtonGroup aria-label="Theme toggle">
<Button variant="secondary" onClick={handleLightThemeClick}>
Light
</Button>
<Button variant="secondary" onClick={handleDarkThemeClick}>
Dark
</Button>
</ButtonGroup>
</div>
<div className="flex-grow-1 d-flex flex-column justify-content-center align-items-center">
<FaCoffee size={100} className="app__logo mb-5" />
<div className="d-flex">
<Button className="app__button" type="button">
Subscribe
</Button>
</div>
</div>
</div>
)
}
Исходный код Очередь обновлений состояния в React
Содержание туториала по React
Установка переменной состояния поставит в очередь следующий рендеринг. Но иногда нужно выполнить несколько операций над значением перед постановкой в очередь следующего рендеринга. Для этого полезно понять, как React пакетно обновляет состояние (батчинг).
React батчит обновления состояний
Батчинг - это объединение обновлений в одну операцию.
В следующем примере, вы можете ожидать, что нажатие кнопки «+3» увеличит счетчик три раза, потому что он трижды вызывает setNumber(number + 1)
:
import { useState } from 'react';
export default function Counter() {
const [number, setNumber] = useState(0);
return (
<>
<h1>{number}</h1>
<button
onClick={() => {
setNumber(number + 1);
setNumber(number + 1);
setNumber(number + 1);
}}
>
+3
</button>
</>
);
}
Однако, как вы, возможно, помните из предыдущего раздела, значения состояния каждого рендеринга фиксированы, поэтому значение number
внутри обработчика событий первого рендеринга всегда равно 0
, независимо от того, сколько раз вы вызываете setNumber(1)
:
setNumber(0 + 1);
setNumber(0 + 1);
setNumber(0 + 1);
Но здесь есть еще один фактор, который нужно обсудить. React ждет, пока весь код в обработчиках событий будет запущен, прежде чем обрабатывать ваши обновления состояния. Вот почему повторный рендеринг происходит только после всех этих вызовов setNumber()
.
Это может напомнить вам официанта, принимающего заказ в ресторане. Официант не бежит на кухню при упоминании вашего первого блюда. Вместо этого они позволяют вам закончить свой заказ, позволяют вносить в него изменения и даже принимать заказы от других людей за столом.
Это позволяет вам обновлять несколько переменных состояния — даже из нескольких компонентов — без запуска слишком большого количества повторных рендерингов. Но это также означает, что пользовательский интерфейс не будет обновляться до тех пор, пока ваш обработчик событий и любой код в нем не завершится. Такое поведение, также известное как пакетная обработка (батчинг), позволяет вашему приложению React работать намного быстрее. Это также позволяет избежать сбивающих с толку «недоделанных» рендеров, в которых были обновлены только некоторые переменные.
React не объединяет несколько преднамеренных событий, таких как клики, — каждый клик обрабатывается отдельно. Будьте уверены, что React выполняет пакетную обработку только тогда, когда это в целом безопасно. Это гарантирует, что, например, если первый клик кнопки отключит форму, второй щелчок не отправит ее снова.
Обновление одной и той же переменной состояния несколько раз перед следующим рендерингом
Это необычный вариант использования, но если вы хотите обновить одну и ту же переменную состояния несколько раз перед следующим рендерингом, вместо передачи следующего значения состояния, такого как setNumber(number + 1)
, вы можете передать функцию, которая вычисляет следующее состояние. на основе предыдущего в очереди, например setNumber(n => n + 1)
. Это способ сказать React «сделать что-нибудь со значением состояния», а не просто заменить его.
Вот как это выглядит на практике:
import { useState } from 'react';
export default function Counter() {
const [number, setNumber] = useState(0);
return (
<>
<h1>{number}</h1>
<button
onClick={() => {
setNumber((n) => n + 1);
setNumber((n) => n + 1);
setNumber((n) => n + 1);
}}
>
+3
</button>
</>
);
}
Здесь n => n + 1
называется функцией обновления. Когда вы передаете его установщику состояния:
- React ставит эту функцию в очередь для обработки после выполнения всего остального кода в обработчике событий.
- Во время следующего рендеринга React проходит через очередь и выдает вам окончательное обновленное состояние.
setNumber((n) => n + 1);
setNumber((n) => n + 1);
setNumber((n) => n + 1);
Вот как React работает с этими строками кода при выполнении обработчика событий:
setNumber(n => n + 1)
: n => n + 1
— это функция. React добавляет его в очередь.
setNumber(n => n + 1)
: n => n + 1
— это функция. React добавляет его в очередь.
setNumber(n => n + 1)
: n => n + 1
— это функция. React добавляет его в очередь.
Когда вы вызываете useState
во время следующего рендеринга, React проходит через очередь. Предыдущее числовое состояние было 0
, так что это то, что React передает первой функции обновления в качестве аргумента n
. Затем React берет возвращаемое значение вашей предыдущей функции обновления и передает его следующему обновлению как n
и так далее:
запланированное обновление | n | возвращает |
---|
n => n + 1 | 0 | 0 + 1 = 1 |
n => n + 1 | 1 | 1 + 1 = 2 |
n => n + 1 | 2 | 2 + 1 = 3 |
React сохраняет 3
в качестве конечного результата и возвращает его из useState
.
Вот почему нажатие «+3» в приведенном выше примере корректно увеличивает значение на 3
.
Что произойдет, если вы обновите состояние после его замены
А как насчет этого обработчика событий? Как вы думаете, какое число будет в следующем рендере?
<button onClick={() => {
setNumber(number + 5);
setNumber(n => n + 1);
}}>
import { useState } from 'react';
export default function Counter() {
const [number, setNumber] = useState(0);
return (
<>
<h1>{number}</h1>
<button
onClick={() => {
setNumber(number + 5);
setNumber((n) => n + 1);
}}
>
Increase the number
</button>
</>
);
}
Вот что этот обработчик событий говорит React:
setNumber(number + 5)
: number
равно 0
, поэтому получаем setNumber(0 + 5)
. React добавляет в свою очередь «заменить на 5».
setNumber(n => n + 1)
: n => n + 1
— это функция обновления. React добавляет эту функцию в свою очередь.
Во время следующего рендера React проходит через очередь состояний:
запланированное обновление | n | возвращает |
---|
"заменить на 5" | 0 (не используется) | 5 |
n => n + 1 | 5 | 5 + 1 = 6 |
React сохраняет 6
в качестве конечного результата и возвращает его из useState
.
Вы могли заметить, что setState(x)
на самом деле работает как setState(n => x)
, но n
не используется.
Что произойдет, если вы замените состояние после его обновления
Давайте попробуем еще один пример. Как вы думаете, какое число будет в следующем рендере?
<button onClick={() => {
setNumber(number + 5);
setNumber(n => n + 1);
setNumber(42);
}}>
import { useState } from 'react';
export default function Counter() {
const [number, setNumber] = useState(0);
return (
<>
<h1>{number}</h1>
<button
onClick={() => {
setNumber(number + 5);
setNumber((n) => n + 1);
setNumber(42);
}}
>
Increase the number
</button>
</>
);
}
Вот как React работает с этими строками кода при выполнении этого обработчика событий:
setNumber(number + 5)
: number
равно 0
, поэтому setNumber(0 + 5)
. React добавляет в свою очередь «заменить на 5».
setNumber(n => n + 1)
: n => n + 1
— это функция обновления. React добавляет эту функцию в свою очередь.
setNumber(42)
: React добавляет в свою очередь «заменить на 42».
Во время следующего рендера React проходит через очередь состояний:
запланированное обновление | n | возвращает |
---|
"заменить на 5" | 0 (не используется) | 5 |
n => n + 1 | 5 | 5 + 1 = 6 |
"заменить на 42" | 6 (не используется) | 42 |
Затем React сохраняет 42
как окончательный результат и возвращает его из useState
.
Подводя итог, вот как вы можете думать о том, что вы передаете установщику состояния setNumber
:
- Функция обновления (например,
n => n + 1
) добавляется в очередь.
- Любое другое значение (например, число
5
) добавляет в очередь «заменить на 5», игнорируя то, что уже находится в очереди.
После завершения обработчика событий React запустит повторный рендеринг. Во время повторного рендеринга React обработает очередь. Функции обновления запускаются во время рендеринга, поэтому функции обновления должны быть чистыми и возвращать только результат. Не пытайтесь установить состояние внутри них или запустить другие побочные эффекты. В строгом режиме React дважды запускает каждую функцию обновления (но отбрасывает второй результат), чтобы помочь вам найти ошибки.
Соглашения об именах
Аргумент функции обновления принято называть первыми буквами соответствующей переменной состояния:
setEnabled((e) => !e);
setLastName((ln) => ln.reverse());
setFriendCount((fc) => fc * 2);
Если вы предпочитаете более подробный код, другим распространенным соглашением является повторение полного имени переменной состояния, например setEnabled(enabled => !enabled)
, или использование префикса, такого как setEnabled(prevEnabled => !prevEnabled)
.
Резюме
- Установка состояния не изменяет переменную в существующем рендеринге, но запрашивает новый рендеринг.
- React обрабатывает обновления состояния после завершения работы обработчиков событий. Это называется батчингом.
- Чтобы обновить некоторое состояние несколько раз в одном событии, вы можете использовать функцию обновления
setNumber(n => n + 1)
.