Гид по технологиям

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

6 min read Frontend Обновлено 03 Jan 2026
Переключатель темы в React без Context
Переключатель темы в 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-атрибуты.

Минимальные тест-кейсы

  1. Нажать кнопку — проверить изменение body.dataset.theme.
  2. Перезагрузить страницу — проверить, что тема восстанавливается.
  3. Очистить localStorage и изменить системную тему — проверить, что тема меняется автоматически.
  4. Включить prefers-reduced-motion и проверить отсутствие анимаций.
  5. Тестирование в браузерах без 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 существующим решением

  1. Найдите точки, где тема читается через Context.
  2. Перепишите компоненты на использование CSS-переменных (var(–…)).
  3. Установите data-theme на body при инициализации приложения (из localStorage либо media query).
  4. Оставьте Context только как обёртку, если нужен programmatic доступ к теме (например, для логики), иначе удалите.

Совместимость и подводные камни

  • matchMedia и localStorage доступны в большинстве современных браузеров; при SSR их может не быть — проверяйте typeof window и document.
  • Старые версии Safari использовали .addListener вместо .addEventListener для MediaQueryList.
  • Если приложение работает в приватном режиме, localStorage может быть недоступен.

Приватность и GDPR-подсказки

Хранение темы — это минимальная персональная настройка; по закону это не чувствительная персональная информация. Тем не менее:

  • Явное согласие обычно не требуется для хранения настройки интерфейса в localStorage.
  • Если вы будете синхронизировать тему с сервером и привязывать к профилю, укажите это в политике конфиденциальности.

Краткое руководство по внедрению (playbook)

  1. Добавьте CSS переменные и правила для body[data-theme].
  2. Создайте компонент Button согласно примеру.
  3. Убедитесь, что useEffect безопасно работает в SSR-окружениях.
  4. Проведите тесты по чек-листам.
  5. Разверните и следите за обратной связью по 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.

Поделиться: X/Twitter Facebook LinkedIn Telegram
Автор
Редакция

Похожие материалы

RDP: полный гид по настройке и безопасности
Инфраструктура

RDP: полный гид по настройке и безопасности

Android как клавиатура и трекпад для Windows
Гайды

Android как клавиатура и трекпад для Windows

Советы и приёмы для работы с PDF
Документы

Советы и приёмы для работы с PDF

Calibration в Lightroom Classic: как и когда использовать
Фото

Calibration в Lightroom Classic: как и когда использовать

Отключить Siri Suggestions на iPhone
iOS

Отключить Siri Suggestions на iPhone

Рисование таблиц в Microsoft Word — руководство
Office

Рисование таблиц в Microsoft Word — руководство