React Theme Provider: покроковий гід

Перекладено ШІ
Оригінал: Laravel News
Оновлено: 20 березня, 2025
Досліджуйте, як впровадити світлий та темний режими у своєму додатку на Laravel за допомогою React та Inertia.js! У цій статті ми розглянемо, як можна легко передати управління темами, щоб забезпечити чудовий досвід для всіх користувачів, навіть тих, хто не зареєстрований у вашому додатку. Чи готові ви вдосконалити інтерфейс свого проєкту? Читайте далі!

Якщо ви вже встигли встановити новий стартер-кит Laravel з React та Inertia.js, ви знаєте, наскільки це чудова основа для ваших нових проєктів. Команда, яка його розробила, виконала неймовірну роботу, надавши спільноті надійний продукт.

У сучасному вебі наявність світлої та темної теми за замовчуванням — це, принаймні для мене, важлива складова. У всіх стартер-китах є можливість для зареєстрованого користувача перейти до налаштувань > зовнішній вигляд та вибрати бажаний режим. Доступні три опції: світлий режим, темний режим або автоматичний, що підлаштовується під налаштування системи користувача.

Однак не вистачало можливості доступу до цих налаштувань для користувачів, які не зареєстровані у вашому додатку! На щастя, стартер з React, на якому я сьогодні зосереджу свою увагу, побудовано так, що цю функцію реалізувати надзвичайно просто!

# Актуальна реалізація: підхід кастомного хуку

У наявному стартер-киті управління темою реалізовано за допомогою кастомного хуку useAppearance. Цей хук чудово працює для зареєстрованих користувачів, але не доступний для гостей.

Ось спрощена версія того, як працює поточна реалізація:

// Актуальна реалізація за допомогою кастомного хуку
function useAppearance() {
  const [appearance, setAppearance] = useState('system');

  // Допоміжні функції для управління темою...

  // Функція оновлення
  const updateAppearance = (mode) => {
    setAppearance(mode);
    localStorage.setItem('appearance', mode);
    // Застосувати зміни теми...
  };

  return { appearance, updateAppearance };
}

// У компоненті Settings
function AppearanceSettings() {
  const { appearance, updateAppearance } = useAppearance();

  return (
    
{/* Інтерфейс налаштувань теми */}
); }

Ця реалізація добре працює, якщо нам потрібно керувати темою в одному місці, але виникають труднощі, коли потрібно:

  1. Поділитися станом теми між кількома компонентами
  2. Дозволити незареєстрованим користувачам налаштовувати свої вподобання теми
  3. Зберегти консистентність у всьому застосунку

# Головна проблема: Prop Drilling

У додатках React дані зазвичай передаються від батьківських компонентів до дочірніх через props (властивості). Це працює добре для простих дерев компонентів, але швидко стає складним, коли програми ускладнюються. Ця проблема не є винятково проблемою React; аналогічні ситуації виникають і в Vue, і в Svelte.

Як це може виглядати? Уявімо, що у вас є глибоко вкладена структура компонентів:

App
└── Header
    └── Navigation
        └── UserMenu
            └── DarkModeToggle

Якщо управління налаштуваннями теми відбувається у компоненті App, але вам потрібно в компоненті DarkModeToggle, вам традиційно потрібно буде передати ці дані через кожен проміжний компонент:

<App theme={theme} setTheme={setTheme}>
  <Header theme={theme} setTheme={setTheme}>
    <Navigation theme={theme} setTheme={setTheme}>
      <UserMenu theme={theme} setTheme={setTheme}>
        <DarkModeToggle theme={theme} setTheme={setTheme} />
      </UserMenu>
    </Navigation>
  </Header>
</App>

Цей патерн передачі props через компоненти, які їх фактично не використовують, називається "prop drilling" і має кілька недоліків:

# Рішення: Патерн Context/Provider

Патерн Context/Provider вирішує цю проблему, створюючи "тунель", який дозволяє даним безпосередньо передаватися від компонента-постачальника до будь-якого компонента-споживача у дереві, незалежно від глибини вкладеності.

# Як це працює

  1. Створення контексту: Спочатку ви створюєте об'єкт контексту, використовуючи функцію createContext() з React. Цей контекст визначає область спільних даних.
  2. Налаштування постачальника: Ви обертаєте частину дерева компонентів постачальником. Постачальник визначає, які дані доступні всім компонентам у його обсязі.
  3. Споживання контексту: Будь-який компонент у дереві постачальника може отримати доступ до спільних даних без props.

Перейшовши від кастомного хуку до контекстного постачальника, ми зможемо:

# Кроки реалізації

Давайте розглянемо нашу реалізацію поетапно:

# 1. Визначення типів та інтерфейсів

Спочатку визначимо нашу структуру даних:

export type Appearance = 'light' | 'dark' | 'system';

interface ThemeContextType {
  appearance: Appearance;
  updateAppearance: (mode: Appearance) => void;
}

Це дає нам чітке уявлення про те, що наш контекст теми буде забезпечувати.

# 2. Основні допоміжні функції теми

Перед створенням постачальника розглянемо допоміжні функції, які нам знадобляться:

// Перевірити, чи система віддає перевагу темному режиму
const prefersDark = () => {
  if (typeof window === 'undefined') {
    return false;
  }
  return window.matchMedia('(prefers-color-scheme: dark)').matches;
};

// Зберегти перевагу у куках для серверного рендерингу
const setCookie = (name: string, value: string, days = 365) => {
  if (typeof document === 'undefined') {
    return;
  }
  const maxAge = days * 24 * 60 * 60;
  document.cookie = `${name}=${value};path=/;max-age=${maxAge};SameSite=Lax`;
};

// Застосувати тему, перемикаючи клас на HTML-елементі
const applyTheme = (appearance: Appearance) => {
  const isDark = appearance === 'dark' || (appearance === 'system' && prefersDark());
  document.documentElement.classList.toggle('dark', isDark);
};

// Отримати медіа-запит для переваги системної теми
const mediaQuery = () => {
  if (typeof window === 'undefined') {
    return null;
  }
  return window.matchMedia('(prefers-color-scheme: dark)');
};

// Обробка змін системної теми
const handleSystemThemeChange = () => {
  const currentAppearance = localStorage.getItem('appearance') as Appearance;
  applyTheme(currentAppearance || 'system');
};

// Ініціалізація теми при завантаженні сторінки
export function initializeTheme() {
  const savedAppearance = (localStorage.getItem('appearance') as Appearance) || 'system';
  applyTheme(savedAppearance);
  mediaQuery()?.addEventListener('change', handleSystemThemeChange);
}

Кожна функція має свою специфічну мету в нашій системі управління темою.

# 3. Створення постачальника контексту

Тепер створимо наш контекст і постачальника:

import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from 'react';

const ThemeContext = createContext(undefined);

export function ThemeProvider({ children }: { children: ReactNode }) {
  const [appearance, setAppearance] = useState('system');

  const updateAppearance = useCallback((mode: Appearance) => {
    setAppearance(mode);
    localStorage.setItem('appearance', mode);
    setCookie('appearance', mode);
    applyTheme(mode);
  }, []);

  useEffect(() => {
    // Ініціалізувати з localStorage при монтуванні
    const savedAppearance = localStorage.getItem('appearance') as Appearance | null;
    updateAppearance(savedAppearance || 'system');

    // Очистити слухача подій при демонтажі
    return () => mediaQuery()?.removeEventListener('change', handleSystemThemeChange);
  }, [updateAppearance]);

  return (
    
      {children}
    
  );
}

Постачальник охоплює управління станом для нашої теми та надає його всім дочірнім компонентам.

# 4. Кастомний хук для споживання контексту

Створимо простий хук, щоб компоненти могли легко використовувати наш контекст:

export function useTheme() {
  const context = useContext(ThemeContext);
  if (context === undefined) {
    throw new Error('useTheme must be used within a ThemeProvider');
  }
  return context;
}

Це надає нам чистий API для доступу до даних теми в будь-якій частині нашого додатку.

# Створення споживчих компонентів

Тепер, коли ми налаштували наш постачальник, давайте створимо компонент, що дозволяє користувачам перемикати теми в будь-якому місці додатку:

// components/ThemeToggleFloat.tsx
import { useTheme, type Appearance } from '@/lib/theme-provider';
import { Monitor, Moon, Sun } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useState } from 'react';

export function ThemeToggleFloat() {
  const { appearance, updateAppearance } = useTheme();
  const [isExpanded, setIsExpanded] = useState(false);
  const toggleExpanded = () => setIsExpanded(!isExpanded);

  const options: { value: Appearance; icon: React.FC>; label: string }[] = [
    { value: 'light', icon: Sun, label: 'Світлий' },
    { value: 'dark', icon: Moon, label: 'Темний' },
    { value: 'system', icon: Monitor, label: 'Автоматичний' },
  ];

  return (
    
{isExpanded && (
{options.map(({ value, icon: Icon, label }) => ( ))}
)}
); }

Цей компонент створює плаваючу кнопку, яка розширюється, щоб показати варіанти тем. Коли користувач обирає опцію, викликається updateAppearance(value) з одним з трьох значень: light, dark або system.

# Інтеграція з додатком

Останнім кроком буде інтеграція нашого постачальника тем у додаток. Оберемо весь наш додаток постачальником, щоб кожен компонент отримав доступ до контексту тем:

// app.tsx
import '../css/app.css';

import { createInertiaApp } from '@inertiajs/react';
import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
import { createRoot } from 'react-dom/client';
import { ThemeProvider, initializeTheme } from './lib/theme-provider';
import { FloatingThemeProvider } from './components/floating-theme-provider';

const appName = import.meta.env.VITE_APP_NAME || 'Laravel';

createInertiaApp({
    title: (title) => `${title} - ${appName}`,
    resolve: (name) => resolvePageComponent(`./pages/${name}.tsx`, import.meta.glob('./pages/**/*.tsx')),
    setup({ el, App, props }) {
        const root = createRoot(el);

        root.render(
            
                
                    
                
            
        );
    },
    progress: {
        color: '#4B5563',
    },
});

// Це налаштує світлий / темний режим при завантаженні
initializeTheme();

Ми також створили просту обгортку для нашої плаваючої кнопки перемикання:

// components/floating-theme-provider.tsx
import { ThemeToggleFloat } from '@/components/theme-toggle-float';
import { ReactNode } from 'react';

export function FloatingThemeProvider({ children }: { children: ReactNode }) {
  return (
    <>
      {children}
      
    
  );
}

Це додає наше плаваюче перемикання тем на кожній сторінці, зберігаючи при цьому початкову структуру App.

# Переваги цього підходу

Перейшовши до патерну Context/Provider, ми отримали кілька переваг:

  1. Глобальна доступність: Будь-який компонент може отримати доступ і оновити вподобання теми
  2. Єдине джерело правди: Вся логіка теми централізована в одному постачальнику
  3. Чистий API: Компоненти використовують простий хук useTheme() без prop drilling
  4. Консистентність: Вподобання теми зберігаються на всіх сторінках та компонентах
  5. Покращений досвід користування: Як гості, так і зареєстровані користувачі можуть налаштовувати свій досвід

# Висновок

Патерн Context/Provider надає потужний спосіб управління глобальним станом у додатках React. Перейшовши від кастомного хуку до постачальника контексту, ми зробили вподобання теми доступними по всьому додатку, не втрачаючи чистоту та підтримуваність коду.

Якщо ви зараз запустите npm run build, ви побачите, що функціонал вашого додатку залишився незмінним, але ми суттєво поліпшили його архітектуру та додали плаваючу кнопку перемикання тем, що працює для всіх користувачів!

Якщо ви хочете ознайомитись зі змінами, внесеними до стартера, перегляньте тут. Я створив pull request до поточної гілки main стартеру з react/inertia. Це допоможе вам побачити повний список змін.

Як завжди, мені цікаво дізнатися вашу думку!