Skip to main content
Блогқа оралу
next.jsreact-19dark-modezustandperformance

Next.js Dark Mode: жыпылықтаусыз және React 19 ескертулерінсіз

Next.js 15+ жүйесінде жыпылықтаусыз қараңғы режим үшін next-themes орнына Zustand + useServerInsertedHTML қалай қолдану керек, React 19 скрипт ескертулерінсіз.

Жарияланды 2026 ж. 11 сәуір8 мин оқу

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 internals (minified)
// 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» ескертуін емес.

Біз нені сынап көрдік (және неге сәтсіз болды)

Шешімді табу алдында біз әрбір тәсілді жүйелі түрде сынап көрдік:

AttemptResult
suppressHydrationWarning on <html>Suppresses hydration mismatch, but NOT the script tag warning
Delay mount with useState + useEffectWarning disappears, but causes FOUC (theme flash)
Raw <script> in layout <head>React 19 catches it — same warning even in Server Components
next/script with beforeInteractiveStill rendered inside React tree — same warning
Remove <head>, put Script in bodySame warning — it's inside <html> which is React-managed
next-themes@1.0.0-beta.0Beta, 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 components

1-қадам: Zustand store

Store тема күйін басқарады, DOM-ға класстар қолданады, жүйелік теманы анықтауды өңдейді және қойындылар арасында синхрондайды. _init() әдісі useEffect ішінде қолдануға арналған тазалау функциясын қайтарады:

store/use-theme-store.ts
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-ды инициализациялайды:

providers/theme-provider.tsx
"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

app/layout.tsx
import { ThemeProvider } from "@/providers/theme-provider"

export default function RootLayout({ children }) {
  return (
    <html lang="en" suppressHydrationWarning>
      <body>
        <ThemeProvider disableTransitionOnChange>
          {children}
        </ThemeProvider>
      </body>
    </html>
  )
}

4-қадам: Оны қолдану

components/theme-switch.tsx
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 әдейі бірдей жасалған. Көшу — әр файлда бір ғана импортты өзгерту:

migration
- 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-themesZustand + useServerInsertedHTML
React 19 warningYes — <script> in client componentNo
FOUC preventionYesYes
Cross-tab syncYesYes
System theme detectionYesYes
disableTransitionOnChangeYesYes
Bundle size~3.5 KB~1.5 KB (uses existing Zustand)
Dependencies+1 (next-themes)0 (Zustand already in project)
Maintenance riskAbandoned since March 2025Your 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 қажет.

Қорытынды

  1. next-themes нақты түрде тасталған — соңғы шығарылым 2025 жылдың наурызы, React 19 ескертуі түзетілмеген
  2. useServerInsertedHTML — React ескертулерінсіз скрипт инъекциялауға арналған дұрыс Next.js примитиві
  3. Zustand Context провайдеріне қарағанда азырақ кодпен реактивті тема күйін қамтамасыз етеді
  4. Бүкіл шешім — ~100 жол, нөл жаңа тәуелділік, әрі әр жолды өзіңіз иеленесіз

Дереккөздер