Руководство по переключению тем (light/dark) без мигания: Vite — React Context + inline-скрипт, Next.js — next-themes.
Преимущество: Изоляция логики темы от бизнес-компонентов. Автоматическое сохранение выбора пользователя и отсутствие 'мигания' при загрузке.
Находится в src/app/providers/theme/. Отвечает за глобальную инициализацию.
Контекст, хуки и UI-компонент выносятся в shared/, так как они переиспользуемы.
light, dark) и структуру контекста для управления состоянием.1export type TTheme = "light" | "dark";
2
3export interface IThemeContextType {
4 theme: TTheme;
5 toggleTheme: () => void;
6}1import { createContext } from "react";
2import { IThemeContextType } from "./theme.types";
3
4export const ThemeContext = createContext<IThemeContextType | undefined>(undefined);useTheme с проверкой на наличие провайдера.1import { useContext } from "react";
2import { ThemeContext } from "./theme.context";
3
4export const useTheme = () => {
5 const context = useContext(ThemeContext);
6 if (!context) {
7 throw new Error("useTheme must be used within a ThemeProvider");
8 }
9 return context;
10};index.html ставит класс на html до первой отрисовки (сохранённый выбор или prefers-color-scheme). Провайдер берёт этот класс как начальное состояние, следует за системной темой, пока пользователь не выбрал свою, и сохраняет выбор в localStorage. На Next.js вместо этого — next-themes.1<script>
2 (function () {
3 try {
4 var saved = localStorage.getItem("theme");
5 var system = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
6 document.documentElement.classList.add(saved === "light" || saved === "dark" ? saved : system);
7 } catch (e) {
8 document.documentElement.classList.add("light");
9 }
10 })();
11</script>1import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
2import { type TTheme, ThemeContext } from "@/shared/ui/layout/theme-toggle";
3
4const STORAGE_KEY = "theme";
5const DARK_QUERY = "(prefers-color-scheme: dark)";
6
7const getSystemTheme = (): TTheme =>
8 window.matchMedia(DARK_QUERY).matches ? "dark" : "light";
9
10/** Класс уже выставлен inline-скриптом до первой отрисовки */
11const getInitialTheme = (): TTheme =>
12 document.documentElement.classList.contains("dark") ? "dark" : "light";
13
14export const ThemeProvider = ({ children }: { children: ReactNode }) => {
15 const [theme, setTheme] = useState<TTheme>(getInitialTheme);
16
17 // Следуем за системной темой, пока нет явного выбора
18 useEffect(() => {
19 const media = window.matchMedia(DARK_QUERY);
20 const onChange = () => {
21 if (!localStorage.getItem(STORAGE_KEY)) setTheme(getSystemTheme());
22 };
23 media.addEventListener("change", onChange);
24 return () => media.removeEventListener("change", onChange);
25 }, []);
26
27 useEffect(() => {
28 const root = document.documentElement;
29 root.classList.remove("light", "dark");
30 root.classList.add(theme);
31 }, [theme]);
32
33 const toggleTheme = useCallback(() => {
34 setTheme((prev) => {
35 const next = prev === "light" ? "dark" : "light";
36 localStorage.setItem(STORAGE_KEY, next);
37 return next;
38 });
39 }, []);
40
41 const value = useMemo(() => ({ theme, toggleTheme }), [theme, toggleTheme]);
42
43 return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
44};
45
46// Next.js: вместо своего провайдера — next-themes
47// (attribute="class", defaultTheme="system", enableSystem, <html suppressHydrationWarning>)withTheme оборачивает корневой компонент в ThemeProvider — тема подключается одной строкой: export default withTheme(App).1import type { ComponentType, FC } from "react";
2import { ThemeProvider } from "./theme.provider";
3
4/** Подключение темы одной строкой: export default withTheme(App) */
5export const withTheme = <P extends object>(Component: ComponentType<P>): FC<P> => {
6 const WithTheme: FC<P> = (props) => (
7 <ThemeProvider>
8 <Component {...props} />
9 </ThemeProvider>
10 );
11
12 WithTheme.displayName = `withTheme(${Component.displayName || Component.name || "Component"})`;
13
14 return WithTheme;
15};useTheme для смены визуального стейта.1import { Moon, Sun } from "lucide-react";
2import { Button } from "@/shared/ui";
3import { useTheme } from "../model";
4
5export const ThemeToggle = () => {
6 const { theme, toggleTheme } = useTheme();
7
8 return (
9 <Button onClick={toggleTheme} variant="outline" size="sm">
10 {theme === "light" ? <Moon size={16} /> : <Sun size={16} />}
11 </Button>
12 );
13};