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

Что потребуется
- Node.js и базовый проект на React (create-react-app или аналог).
- Знание React и хуков (useState, useEffect).
- CSS-переменные для тем и возможность менять data-атрибут на body.
Коротко о подходе
Идея проста: компонент Button управляет состоянием theme (“light” или “dark”). При изменении темы мы записываем её в localStorage, устанавливаем data-theme на body, и тем самым активируем соответствующие CSS-переменные. При первом рендере мы берём приоритет: 1) локальное сохранённое предпочтение, 2) системная настройка через prefers-color-scheme, 3) значение по умолчанию.
Важно: такой компонент легко переносить между проектами, он не привязан к дереву компонентов через Context и минимально влияет на повторное использование.
Создание компонента переключения темы
Ниже — полный, исправленный и оптимизированный вариант Button.js. В нём учтены: корректное чтение/запись в localStorage, обработка prefers-color-scheme, доступность (aria-атрибуты) и предотвращение «мигания» при смене темы на клиенте.
import React, { useEffect, useState } from 'react';
const THEME_KEY = 'theme';
const DEFAULT_THEME = 'light';
function getStoredTheme() {
try {
return localStorage.getItem(THEME_KEY);
} catch (e) {
// Если доступ к localStorage невозможен — например, в режиме строгой приватности
return null;
}
}
function getSystemPreference() {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
return null;
}
const mql = window.matchMedia('(prefers-color-scheme: dark)');
if (typeof mql.matches === 'boolean') {
return mql.matches ? 'dark' : 'light';
}
return null;
}
export default function ThemeToggleButton() {
const [theme, setTheme] = useState(() => {
// Отложенная инициализация: сначала пробуем взять из localStorage
const stored = getStoredTheme();
if (stored) return stored;
const system = getSystemPreference();
return system || DEFAULT_THEME;
});
// Синхронизируем data-theme на body и localStorage при изменении theme
useEffect(() => {
try {
document.body.dataset.theme = theme;
} catch (e) {
// Ничего не делаем, если доступ к body невозможен
}
try {
localStorage.setItem(THEME_KEY, theme);
} catch (e) {
// Игнорируем ошибки записи (например, приватный режим)
}
}, [theme]);
// Поддержка изменения системной настройки в реальном времени
useEffect(() => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
const mql = window.matchMedia('(prefers-color-scheme: dark)');
const handler = (e) => {
// Если пользователь явно не сохранил тему в localStorage, обновляем тему при смене системной
if (!getStoredTheme()) {
setTheme(e.matches ? 'dark' : 'light');
}
};
if (typeof mql.addEventListener === 'function') {
mql.addEventListener('change', handler);
} else if (typeof mql.addListener === 'function') {
mql.addListener(handler);
}
return () => {
if (typeof mql.removeEventListener === 'function') {
mql.removeEventListener('change', handler);
} else if (typeof mql.removeListener === 'function') {
mql.removeListener(handler);
}
};
}, []);
const toggle = () => {
setTheme((prev) => (prev === 'light' ? 'dark' : 'light'));
};
return (
);
}Примечания по коду:
- Отложенная инициализация useState позволяет избежать лишних эффектов при монтировании.
- Обёрнуты вызовы localStorage и document.body для безопасной работы в нестандартных средах.
- Добавлена подписка на изменение prefers-color-scheme, чтобы менять тему при смене системной настройки, если пользователь не зафиксировал собственную.
- aria-атрибуты улучшают доступность для экранных читалок.
Подключение в App.js
Пример простого подключения компонента в корень приложения:
import React from 'react';
import ThemeToggleButton from './Button';
function App() {
return (
Моё приложение
Контент страницы...
);
}
export default App;CSS: переменные и темы
Приведу рекомендуемый набор CSS-переменных и стилей на основе data-атрибута body[data-theme]. Это простой и производительный способ менять тему — без перерендеринга React-компонентов.
:root {
--color-text-primary: #131616;
--color-text-secondary: #ff6b00;
--color-bg-primary: #E6EDEE;
--color-bg-secondary: rgba(125, 134, 136, 0.11);
--transition-fast: 0.25s ease-in-out;
}
body {
background: var(--color-bg-primary);
color: var(--color-text-primary);
transition: background var(--transition-fast), color var(--transition-fast);
}
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 12px;
color: var(--color-text-primary);
background: transparent;
border: 1px solid var(--color-text-primary);
border-radius: 6px;
cursor: pointer;
}
.themeBtn:focus {
outline: 3px solid rgba(100, 150, 250, 0.35);
}Совет: добавьте базовые переменные для дополнительных оттенков (фон карточек, бордюры), чтобы тема выглядела цельно.
Предотвращение «мигания» темы при первом рендере
Проблема: при SSR или при начальной загрузке тема может «мигнуть» (flash of wrong theme), если JS загружен позже, чем отображение страницы. Решения:
- Inline-скрипт в , который читает localStorage и ставит data-theme до рендера. Простой пример (вставить в index.html):
- Альтернативно, применяйте нейтральные цвета по умолчанию, которые выглядят хорошо в обеих темах, пока не инициализируется тема.
Доступность и UX
- Используйте aria-pressed, aria-label, и видимое состояние фокуса.
- Предоставьте явную надпись/иконку, понятную без цвета.
- Не меняйте контраст текста ниже минимального уровня (WCAG 2.1 рекомендует контраст не ниже 4.5:1 для обычного текста).
Когда подход с data-атрибутом предпочтительнее Context, и когда нет
Когда data-атрибут лучше:
- Хотите максимально простое решение для глобальных CSS-переменных.
- Нужно мало кода и простая миграция между проектами.
- Минимизировать перерендер компонентов.
Когда Context предпочтительнее:
- Вам нужно, чтобы отдельные компоненты знали тему на уровне JS (например, для условного рендеринга сложных компонентов или передачи темы через props).
- Нужно иметь сложную логику переключения, специфичную для набора компонентов.
Часто используют комбинированный подход: data-атрибут для стилей + Context для логики и доступа из компонентов.
Критерии приёмки
- Кнопка меняет тему при клике и сохраняет выбор в localStorage.
- При перезагрузке тема остается такой же, какой её выбрал пользователь.
- При отсутствии сохранённой настройки тема соответствует системной настройке.
- Кнопка доступна: aria-атрибуты, видно состояние фокуса, контраст в норме.
- Отсутствует нежелательное мигание темы при инициализации (проверено в продакшене).
Тест-кейсы и приёмочные тесты
- Кликаем на кнопку: тема переключается и текст кнопки меняется.
- Сохраняем тему, перезагружаем страницу: тема сохраняется.
- Удаляем ключ theme из localStorage: тема соответствует системе (смена темы ОС меняет тему сайта).
- Браузер в приватном режиме, localStorage не доступен: приложение работает и не ломается.
- Проверить контраст текста для основных элементов в обеих темах.
Роль‑ориентированные чек-листы
Разработчик:
- Добавил запись в localStorage и защитил её try/catch.
- Подписался на prefers-color-scheme change.
- Обновил документацию компонента.
Дизайнер:
- Утверждены переменные цвета для обеих тем.
- Проверены состояния hover/focus/disabled.
QA:
- Выполнил тест-кейсы выше на основных браузерах и мобильных.
- Проверил доступность с помощью экранного ридера.
Возможные альтернативы и расширения
- Использовать Context для передачи темы в JS-код (если нужно условное рендеринг по теме).
- Хранить выбор пользователя на сервере (при аутентификации) для синхронизации между устройствами.
- Поддержка нескольких цветовых схем (не только light/dark) через тот же механизм data-theme.
Проблемы и когда подход не сработает
- Если проект использует изолированные shadow DOM-компоненты, data-атрибут на body не влияет на их стили.
- При SSR без inline-инициализации возможен flash of wrong theme.
- В окружениях, где localStorage отключён, нужно быть готовым работать без сохранения.
Короткий глоссарий
- prefers-color-scheme: медиа-фича, указывающая системную тему пользователя.
- data-theme: data-атрибут на document.body, используемый для переключения CSS-переменных.
- localStorage: браузерное хранилище для сохранения состояния на устройстве.
Резюме
- Реализация через data-атрибуты проста, производительна и хорошо масштабируется по CSS.
- Компонент с useState/useEffect и защитой localStorage покрывает большинство практических случаев.
- Протестируйте поведение при приватном режиме, SSR и смене системной темы.
Важно: выберите стратегию, которая соответствует требованиям проекта — простота и переносимость против полной интеграции с бизнес-логикой через Context.
Похожие материалы
RDP: полный гид по настройке и безопасности
Android как клавиатура и трекпад для Windows
Советы и приёмы для работы с PDF
Calibration в Lightroom Classic: как и когда использовать
Отключить Siri Suggestions на iPhone