Якщо ви вже встигли встановити новий стартер-кит 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 (
{/* Інтерфейс налаштувань теми */}
);
}
Ця реалізація добре працює, якщо нам потрібно керувати темою в одному місці, але виникають труднощі, коли потрібно:
У додатках 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 вирішує цю проблему, створюючи "тунель", який дозволяє даним безпосередньо передаватися від компонента-постачальника до будь-якого компонента-споживача у дереві, незалежно від глибини вкладеності.
createContext()
з React. Цей контекст визначає область спільних даних.Перейшовши від кастомного хуку до контекстного постачальника, ми зможемо:
Давайте розглянемо нашу реалізацію поетапно:
Спочатку визначимо нашу структуру даних:
export type Appearance = 'light' | 'dark' | 'system';
interface ThemeContextType {
appearance: Appearance;
updateAppearance: (mode: Appearance) => void;
}
Це дає нам чітке уявлення про те, що наш контекст теми буде забезпечувати.
Перед створенням постачальника розглянемо допоміжні функції, які нам знадобляться:
// Перевірити, чи система віддає перевагу темному режиму
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);
}
Кожна функція має свою специфічну мету в нашій системі управління темою.
Тепер створимо наш контекст і постачальника:
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}
);
}
Постачальник охоплює управління станом для нашої теми та надає його всім дочірнім компонентам.
Створимо простий хук, щоб компоненти могли легко використовувати наш контекст:
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, ми отримали кілька переваг:
useTheme()
без prop drillingПатерн Context/Provider надає потужний спосіб управління глобальним станом у додатках React. Перейшовши від кастомного хуку до постачальника контексту, ми зробили вподобання теми доступними по всьому додатку, не втрачаючи чистоту та підтримуваність коду.
Якщо ви зараз запустите npm run build
, ви побачите, що функціонал вашого додатку залишився незмінним, але ми суттєво поліпшили його архітектуру та додали плаваючу кнопку перемикання тем, що працює для всіх користувачів!
Якщо ви хочете ознайомитись зі змінами, внесеними до стартера, перегляньте тут. Я створив pull request до поточної гілки main
стартеру з react/inertia. Це допоможе вам побачити повний список змін.
Як завжди, мені цікаво дізнатися вашу думку!