Переключатель тёмной/светлой темы в React без Context

Что понадобится
- Современная версия Node и рабочая среда для React-приложения.
- Базовые знания React и хуков (useState, useEffect).
- Стартовый проект React (create-react-app или аналог).
Почему не Context? Коротко
React context удобен для глобального состояния, но делает переиспользуемость компонентов и тестирование иногда сложнее. Для простой задачи — переключения темы — достаточно управлять state локально, ставить data-атрибут на body и применять CSS-переменные. Это лёгкая, ясная и декларативная схема.
Important: если вам нужно распределённое управление темой между множеством независимых частей приложения (или синхронизация с сервером), context или глобальное состояние всё же уместны.
Создание компонента Button — идея
Кнопка будет переключать значение темы и записывать выбор пользователя в localStorage. Кроме того, при загрузке компонент будет читать сохранённое значение и учёт системного предпочтения через prefers-color-scheme.
Ключевые приёмы:
- data-атрибут body[data-theme] для переключения CSS-переменных;
- localStorage для хранения выбора пользователя;
- matchMedia для получения системного предпочтения и подписки на изменения.
Полный пример Button.js
Ниже — рабочая версия компонента. Она учитывает: начальное значение, сохранённую настройку, системное предпочтение; подписывается на изменения prefers-color-scheme; очищает слушатель; добавляет атрибут aria для доступности.
import React, { useEffect, useState } from 'react';
export default function Button() {
const [theme, setTheme] = useState('dark');
const LOCAL_KEY = 'theme';
const storeUserPreference = (pref) => {
try {
localStorage.setItem(LOCAL_KEY, pref);
} catch (e) {
// localStorage может быть недоступен в некоторых окружениях
// безопасно игнорируем ошибку
}
};
const getUserPreference = () => {
try {
return localStorage.getItem(LOCAL_KEY);
} catch (e) {
return null;
}
};
const getMediaQueryPreference = () => {
if (typeof window === 'undefined' || !window.matchMedia) return null;
const mql = window.matchMedia('(prefers-color-scheme: dark)');
if (typeof mql.matches === 'boolean') {
return mql.matches ? 'dark' : 'light';
}
return null;
};
// Переключение темы по нажатию
const handleToggle = () => {
const newTheme = theme === 'light' ? 'dark' : 'light';
setTheme(newTheme);
storeUserPreference(newTheme);
// document.body.dataset.theme = newTheme; // обновится в useEffect после setTheme
};
useEffect(() => {
// Функция для установки темы на body
const applyTheme = (t) => {
if (typeof document !== 'undefined' && document.body) {
document.body.dataset.theme = t;
}
};
// Инициализация: сначала проверяем локальное хранилище, затем медиазапрос
const userPref = getUserPreference();
const mediaPref = getMediaQueryPreference();
if (userPref) {
setTheme(userPref);
applyTheme(userPref);
} else if (mediaPref) {
setTheme(mediaPref);
applyTheme(mediaPref);
} else {
// fallback — уже установлен по умолчанию в useState
applyTheme(theme);
}
// Подписываемся на изменения системных настроек
let mql;
try {
mql = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = (e) => {
const media = e.matches ? 'dark' : 'light';
// если пользователь не сохранил явную настройку, меняем тему
const explicit = getUserPreference();
if (!explicit) {
setTheme(media);
applyTheme(media);
}
};
if (mql && mql.addEventListener) {
mql.addEventListener('change', handleChange);
} else if (mql && mql.addListener) {
// старые браузеры
mql.addListener(handleChange);
}
return () => {
if (mql && mql.removeEventListener) {
mql.removeEventListener('change', handleChange);
} else if (mql && mql.removeListener) {
mql.removeListener(handleChange);
}
};
} catch (e) {
// Игнорируем ошибки окружения
}
}, []); // пустой массив — инициализация один раз
// Обновляем data-атрибут при изменении state (реактивно)
useEffect(() => {
if (typeof document !== 'undefined' && document.body) {
document.body.dataset.theme = theme;
}
}, [theme]);
return (
);
}Notes: код учитывает возможную недоступность localStorage в некоторых окружениях (например, при SSR) и использует безопасные проверки.
CSS: переменные и data-атрибуты
В корне CSS (например, App.css) объявите переменные и переключение по body[data-theme]. Пример:
body {
--color-text-primary: #131616;
--color-text-secondary: #ff6b00;
--color-bg-primary: #E6EDEE;
--color-bg-secondary: #7d86881c;
background: var(--color-bg-primary);
color: var(--color-text-primary);
transition: background 0.25s ease-in-out, color 0.25s ease-in-out;
}
body[data-theme="light"] {
--color-text-primary: #131616;
--color-bg-primary: #E6EDEE;
}
body[data-theme="dark"] {
--color-text-primary: #F2F5F7;
--color-bg-primary: #0E141B;
}
.themeBtn {
padding: 10px;
color: var(--color-text-primary);
background: transparent;
border: 1px solid var(--color-text-primary);
cursor: pointer;
}Преимущества: стили переключаются централизованно, компоненты подхватывают переменные автоматически.
Поведение при загрузке, перезагрузке и при смене системных настроек
- При первой загрузке компонент читает localStorage и applies saved theme.
- Если сохранённого значения нет, компонент использует prefers-color-scheme (media query).
- Если и media query недоступен — используется значение по умолчанию из useState.
- При изменении системной темы (например, в macOS или Windows) компонент может автоматически менять тему, если пользователь не зафиксировал выбор (не сохранил в localStorage).
Доступность (A11y) и UX улучшения
- aria-pressed и aria-label помогают людям с экранными читалками понять текущее состояние кнопки.
- Добавьте видимый фокус (outline) для клавиатурной навигации.
- Подумайте о Reduced Motion: если у пользователя включён prefers-reduced-motion, уменьшите анимации.
Пример: в CSS
@media (prefers-reduced-motion: reduce) {
:root { --transition-duration: 0s; }
body { transition-duration: 0s; }
}
.themeBtn:focus {
outline: 3px solid var(--color-text-secondary);
outline-offset: 2px;
}Тесты и критерии приёмки
Критерии приёмки
- Кнопка переключает тему и меняет data-theme на body.
- Выбор пользователя сохраняется в localStorage и восстанавливается после перезагрузки.
- Если нет сохранённого выбора — используется prefers-color-scheme.
- При изменении системного предпочтения тема обновляется, если пользователь не сохранял явный выбор.
- Кнопка доступна по клавиатуре и имеет корректные aria-атрибуты.
Минимальные тест-кейсы
- Нажать кнопку — проверить изменение body.dataset.theme.
- Перезагрузить страницу — проверить, что тема восстанавливается.
- Очистить localStorage и изменить системную тему — проверить, что тема меняется автоматически.
- Включить prefers-reduced-motion и проверить отсутствие анимаций.
- Тестирование в браузерах без matchMedia: поведение не ломается.
Когда этот подход не подходит — контрпримеры
- Очень большая система, где тема должна меняться синхронно на сервере и клиенте (SSR) и быть частью API-профиля пользователя — лучше использовать глобальное состояние и синхронизировать с сервером.
- Если несколько вкладок должны синхронно переключать тему при действии в одной из них, можно либо подписаться на событие storage (window.addEventListener(‘storage’, …)), либо использовать shared worker / broadcast channel.
Альтернативы и сравнение
- CSS data-атрибут + localStorage — простота, хорошая производительность, легко тестировать.
- React Context — удобен, если множество вложенных компонентов должны потреблять тему как пропс без обращения к CSS-переменным; усложняет тесты и повторное использование компонента вне оболочки Context.
- State management (Redux, Zustand) — нужно, если тема связана с другими частями состояния и/или требуется централизованная логика.
Небольшая таблица сравнений (схематично):
- Простота: data-атрибут > Context > Redux
- Гибкость: Redux > Context > data-атрибут
- Производительность: data-атрибут (CSS) быстрый
Чек-листы по ролям
Для разработчика
- Добавить safe-guards для localStorage (SSR).
- Подписаться на matchMedia и очищать слушатели.
- Добавить aria-атрибуты и видимый фокус.
- Написать unit/приграничные тесты.
Для дизайнера
- Подготовить переменные темы (цвета, тени, контрасты).
- Проверить контрастность текста и элементов в обеих темах.
- Указать поведение при reduced-motion.
Для QA
- Проверить восстановление темы после перезагрузки.
- Тестировать изменение системной темы.
- Проверить в старых браузерах и mobile.
Миграция: как заменить Context существующим решением
- Найдите точки, где тема читается через Context.
- Перепишите компоненты на использование CSS-переменных (var(–…)).
- Установите data-theme на body при инициализации приложения (из localStorage либо media query).
- Оставьте Context только как обёртку, если нужен programmatic доступ к теме (например, для логики), иначе удалите.
Совместимость и подводные камни
- matchMedia и localStorage доступны в большинстве современных браузеров; при SSR их может не быть — проверяйте typeof window и document.
- Старые версии Safari использовали .addListener вместо .addEventListener для MediaQueryList.
- Если приложение работает в приватном режиме, localStorage может быть недоступен.
Приватность и GDPR-подсказки
Хранение темы — это минимальная персональная настройка; по закону это не чувствительная персональная информация. Тем не менее:
- Явное согласие обычно не требуется для хранения настройки интерфейса в localStorage.
- Если вы будете синхронизировать тему с сервером и привязывать к профилю, укажите это в политике конфиденциальности.
Краткое руководство по внедрению (playbook)
- Добавьте CSS переменные и правила для body[data-theme].
- Создайте компонент Button согласно примеру.
- Убедитесь, что useEffect безопасно работает в SSR-окружениях.
- Проведите тесты по чек-листам.
- Разверните и следите за обратной связью по UX.
Быстрые сниппеты и подсказки (cheat sheet)
- Сохранить в localStorage: localStorage.setItem(‘theme’, ‘dark’)
- Прочитать: localStorage.getItem(‘theme’)
- Проверить prefers-color-scheme: window.matchMedia(‘(prefers-color-scheme: dark)’).matches
- Подписка на изменение: mql.addEventListener(‘change’, handler) (или addListener для старых)
Решение: когда выбрать Context
flowchart TD
A[Нужно ли менять тему в многих компонентах?] -->|Нет| B[Использовать data-атрибут на body]
A -->|Да| C[Требуется синхронизация с сервером?]
C -->|Да| D[Использовать глобальное состояние 'Redux/Context' + синхронизация API]
C -->|Нет| E[Можно использовать Context и CSS-переменные вместе]Краткое резюме
- Использование data-атрибута на body и CSS-переменных — простой и эффективный способ управлять тёмной/светлой темой.
- Для большинства приложений достаточно useState и useEffect внутри компактного компонента Button, с сохранением выбора в localStorage и учётом prefers-color-scheme.
- Context или глобальное состояние подходят, когда тема — часть сложной логики или должна синхронизироваться между множеством вкладок/сервером.
Summary:
- Лёгкая реализация — проще в поддержке и тестировании.
- Учтите доступность и edge-case’ы (SSR, приватный режим, старые браузеры).
Если нужно, могу подготовить минимальный репозиторий с примером (Create React App) или адаптировать код под Next.js/SSR.
Похожие материалы
RDP: полный гид по настройке и безопасности
Android как клавиатура и трекпад для Windows
Советы и приёмы для работы с PDF
Calibration в Lightroom Classic: как и когда использовать
Отключить Siri Suggestions на iPhone