Next.js Dark Mode: жыпылықтаусыз және React 19 ескертулерінсіз
Next.js 15+ жүйесінде жыпылықтаусыз қараңғы режим үшін next-themes орнына Zustand + useServerInsertedHTML қалай қолдану керек, React 19 скрипт ескертулерінсіз.
TL;DR
next-themes тема жыпылықтауын (FOUC) болдырмау үшін React Client Component ішінде <script> тегін рендерлейді. React 19 енді бұл туралы ескертеді — әрі оны басудың ешқандай жолы жоқ. Кітапхана 2025 жылдың наурызынан бері жаңартылмаған. Шешім: next-themes орнына Zustand store + useServerInsertedHTML қолданып, скриптті React ағашынан тыс инъекциялау. Нөл жаңа тәуелділік. Нөл FOUC. Нөл ескерту.
Мәселе
Егер сіз next-themes кітапханасын Next.js 15+ және React 19 нұсқаларымен қолдансаңыз, әр бет жүктелген сайын консольде мына қатені көресіз:
Encountered a script tag while rendering React component.
Scripts inside React components are never executed when rendering on the client.
Consider using template tag instead.
src/providers/theme-provider.tsx (7:10) @ ThemeProviderБұл гидратация сәйкессіздігі емес. Бұл — клиентте React компоненттері рендерлеген <script> тегтері ешқашан орындалмайтыны туралы React 19-дың айқын ескертуі. Скрипт SSR кезінде жұмыс істейді (ол HTML ішінде бар), бірақ React оны қате деп белгілейді.
Неге бұлай болады
next-themes React гидратациялаудан бұрын <html> элементіне дұрыс тема класын орнатуы қажет — әйтпесе қате теманың жыпылықтауы пайда болады. Ол үшін ол React.createElement арқылы inline <script> инъекциялайды:
// next-themes renders a <script> inside a Client Component
return React.createElement(Provider, { value },
React.createElement("script", {
suppressHydrationWarning: true,
dangerouslySetInnerHTML: { __html: `(...theme init code...)` }
}),
children
)React 19 өз әрекетін өзгертті: компоненттер ішіндегі script тегтері енді айқын белгіленеді. React 19-ға дейін бұл үнсіз еленбейтін. Скрипттегі suppressHydrationWarning пропы көмектеспейді — ол гидратация ескертулерін басады, ал «компонент ішіндегі script» ескертуін емес.
Біз нені сынап көрдік (және неге сәтсіз болды)
Шешімді табу алдында біз әрбір тәсілді жүйелі түрде сынап көрдік:
| Attempt | Result |
|---|---|
suppressHydrationWarning on <html> | Suppresses hydration mismatch, but NOT the script tag warning |
Delay mount with useState + useEffect | Warning disappears, but causes FOUC (theme flash) |
Raw <script> in layout <head> | React 19 catches it — same warning even in Server Components |
next/script with beforeInteractive | Still rendered inside React tree — same warning |
Remove <head>, put Script in body | Same warning — it's inside <html> which is React-managed |
next-themes@1.0.0-beta.0 | Beta, no release date, unclear if fixed |
Шешім: Zustand + useServerInsertedHTML
useServerInsertedHTML — бұл HTML-ды SSR ағынына React компоненттер ағашынан тыс инъекциялайтын Next.js хук. Скрипт HTML ішіне түседі, бірақ React оны клиенттік рендеринг кезінде ешқашан «көрмейді» — сондықтан ешқандай ескерту жоқ. Реактивті тема күйі үшін Zustand store-мен біріктірілгенде, біз тәуелділіксіз толық алмастырғышқа қол жеткіземіз.
Ол қалай жұмыс істейді
SSR (server):
useServerInsertedHTML → injects <script> into HTML stream
↓
Browser receives HTML with theme script already in it
↓
Script runs BEFORE React hydrates → correct class on <html>
↓
No flash. No mismatch. No warning.
Client (after hydration):
useEffect → _init() → Zustand store syncs with localStorage
↓
useTheme() → reactive theme state for components1-қадам: Zustand store
Store тема күйін басқарады, DOM-ға класстар қолданады, жүйелік теманы анықтауды өңдейді және қойындылар арасында синхрондайды. _init() әдісі useEffect ішінде қолдануға арналған тазалау функциясын қайтарады:
import { create } from "zustand";
const STORAGE_KEY = "theme";
const MEDIA_QUERY = "(prefers-color-scheme: dark)";
function getSystemTheme(): "light" | "dark" {
return window.matchMedia(MEDIA_QUERY).matches ? "dark" : "light";
}
function applyTheme(resolved: string, disableTransition: boolean) {
const d = document.documentElement;
if (disableTransition) {
const style = document.createElement("style");
style.appendChild(
document.createTextNode(
"*,*::before,*::after{transition:none!important}"
)
);
document.head.appendChild(style);
window.getComputedStyle(document.body);
setTimeout(() => document.head.removeChild(style), 1);
}
d.classList.remove("light", "dark");
d.classList.add(resolved);
d.style.colorScheme = resolved;
}
interface ThemeState {
theme: string;
systemTheme: "light" | "dark";
resolvedTheme: string;
setTheme: (theme: string) => void;
_init: (disableTransition: boolean) => () => void;
}
export const useThemeStore = create<ThemeState>((set, get) => ({
theme: "system",
systemTheme: "light",
resolvedTheme: "light",
setTheme: (newTheme) => {
const { systemTheme, disableTransitionOnChange } = get();
const resolved = newTheme === "system" ? systemTheme : newTheme;
localStorage.setItem(STORAGE_KEY, newTheme);
applyTheme(resolved, disableTransitionOnChange);
set({ theme: newTheme, resolvedTheme: resolved });
},
_init: (disableTransition) => {
const stored = localStorage.getItem(STORAGE_KEY) || "system";
const system = getSystemTheme();
const resolved = stored === "system" ? system : stored;
set({ theme: stored, systemTheme: system, resolvedTheme: resolved });
applyTheme(resolved, disableTransition);
// System theme changes
const mq = window.matchMedia(MEDIA_QUERY);
const onChange = (e: MediaQueryListEvent) => {
const newSystem = e.matches ? "dark" : "light";
const { theme } = get();
set({ systemTheme: newSystem,
resolvedTheme: theme === "system" ? newSystem : theme });
if (theme === "system") applyTheme(newSystem, disableTransition);
};
mq.addEventListener("change", onChange);
// Cross-tab sync
const onStorage = (e: StorageEvent) => {
if (e.key !== STORAGE_KEY || !e.newValue) return;
const system = getSystemTheme();
const resolved = e.newValue === "system" ? system : e.newValue;
set({ theme: e.newValue, resolvedTheme: resolved });
applyTheme(resolved, disableTransition);
};
window.addEventListener("storage", onStorage);
return () => {
mq.removeEventListener("change", onChange);
window.removeEventListener("storage", onStorage);
};
},
}));
export function useTheme() {
return useThemeStore((s) => ({
theme: s.theme,
setTheme: s.setTheme,
resolvedTheme: s.resolvedTheme,
systemTheme: s.systemTheme,
}));
}2-қадам: ThemeProvider
Provider екі нәрсе жасайды: useServerInsertedHTML арқылы FOUC-тың алдын алу скриптін инъекциялайды және монтаждау кезінде Zustand store-ды инициализациялайды:
"use client"
import { useEffect } from "react"
import { useServerInsertedHTML } from "next/navigation"
import { useThemeStore } from "@/store/use-theme-store"
const THEME_INIT_SCRIPT = `(function(){
try {
var t = localStorage.getItem("theme") || "system";
var r = t;
if (t === "system") {
r = window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark" : "light";
}
document.documentElement.classList.remove("light", "dark");
document.documentElement.classList.add(r);
document.documentElement.style.colorScheme = r;
} catch(e) {}
})()`
export function ThemeProvider({
children,
disableTransitionOnChange = false,
}) {
// Injects script into SSR HTML outside the React tree
// — prevents FOUC without triggering React 19 warning
useServerInsertedHTML(() => (
<script dangerouslySetInnerHTML={{ __html: THEME_INIT_SCRIPT }} />
))
useEffect(() => {
return useThemeStore.getState()._init(disableTransitionOnChange)
}, [disableTransitionOnChange])
return <>{children}</>
}3-қадам: Layout
import { ThemeProvider } from "@/providers/theme-provider"
export default function RootLayout({ children }) {
return (
<html lang="en" suppressHydrationWarning>
<body>
<ThemeProvider disableTransitionOnChange>
{children}
</ThemeProvider>
</body>
</html>
)
}4-қадам: Оны қолдану
import { useTheme } from "@/store/use-theme-store"
export function ThemeSwitch() {
const { theme, setTheme } = useTheme();
return (
<button onClick={() => setTheme(
theme === "dark" ? "light" : "dark"
)}>
{theme === "dark" ? "☀️" : "🌙"}
</button>
);
}next-themes-тен көшу
API әдейі бірдей жасалған. Көшу — әр файлда бір ғана импортты өзгерту:
- import { useTheme } from "next-themes"
+ import { useTheme } from "@/store/use-theme-store"
// API is identical — no other changes needed
const { theme, setTheme, resolvedTheme, systemTheme } = useTheme()Салыстыру
| next-themes | Zustand + useServerInsertedHTML | |
|---|---|---|
| React 19 warning | Yes — <script> in client component | No |
| FOUC prevention | Yes | Yes |
| Cross-tab sync | Yes | Yes |
| System theme detection | Yes | Yes |
disableTransitionOnChange | Yes | Yes |
| Bundle size | ~3.5 KB | ~1.5 KB (uses existing Zustand) |
| Dependencies | +1 (next-themes) | 0 (Zustand already in project) |
| Maintenance risk | Abandoned since March 2025 | Your own code |
Неге басқа баламалар емес?
@wrksz/themes
Ол да useServerInsertedHTML қолданатын тікелей алмастырғыш. Жұмыс істейді, бірақ бұл — бір ғана мейнтейнерден шыққан тағы бір тәуелділік. Егер next-themes бізге бірдеңе үйретсе — тәуелділіктер тасталады. ~100 жол кодпен сіз шешімді толығымен өзіңіздікі ете аласыз.
next-themes@1.0.0-beta.0
npm-де бар, бірақ шығарылым күнінсіз, changelog-сыз және React 19 ескертуі түзетілгені туралы нақты нұсқаусыз. Өндірістік кодты мерзімсіз бета-нұсқаға тігу — алуға тұрарлық тәуекел емес.
Тек CSS (prefers-color-scheme)
Жүйелік теманы анықтауға жарайды, бірақ пайдаланушы таңдауын сақтауды (localStorage), теманы қолмен ауыстыруды немесе «system» опциясын өңдей алмайды. Ол үшін JavaScript қажет.
Қорытынды
next-themesнақты түрде тасталған — соңғы шығарылым 2025 жылдың наурызы, React 19 ескертуі түзетілмегенuseServerInsertedHTML— React ескертулерінсіз скрипт инъекциялауға арналған дұрыс Next.js примитиві- Zustand Context провайдеріне қарағанда азырақ кодпен реактивті тема күйін қамтамасыз етеді
- Бүкіл шешім — ~100 жол, нөл жаңа тәуелділік, әрі әр жолды өзіңіз иеленесіз
Дереккөздер
- shadcn/ui #10104 — Dark mode guide should address React 19 script warning
- next-themes #296 — React 19 support request (open since 2024)
- React #34008 — Script tags not executing in components (by design)
- @wrksz/themes — Drop-in replacement using useServerInsertedHTML
- Next.js Docs — useServerInsertedHTML API