Skip to main content
العودة إلى المدونة
next.jsreact-19dark-modezustandperformance

الوضع الداكن في Next.js دون وميض أو تحذيرات React 19

كيفية استبدال next-themes بـ Zustand مع useServerInsertedHTML للحصول على وضع داكن دون وميض في Next.js 15+، ودون إطلاق تحذيرات سكربت React 19.

نُشر 11 أبريل 20268 دقيقة قراءة

TL;DR

يعرض next-themes وسم <script> داخل مكوّن عميل React لمنع وميض السمة (FOUC). صار React 19 الآن يُصدر تحذيرًا بشأن ذلك — ولا توجد أي طريقة لكتمه. لم تُحدَّث المكتبة منذ مارس 2025. الحل: استبدال next-themes بمخزن Zustand مع 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

هذا ليس عدم تطابق في الإماهة (hydration). إنه تحذير صريح من React 19 بأن وسوم <script> التي تعرضها مكوّنات React على العميل لن تُنفَّذ أبدًا. يعمل السكربت أثناء التصيير من الخادم SSR (فهو موجود في HTML)، لكن React يعتبره غير صحيح.

لماذا يحدث ذلك

يحتاج next-themes إلى ضبط صنف السمة الصحيح على <html> قبل أن يُميه React الصفحة (إماهة/hydration) — وإلا فسيظهر وميض بالسمة الخاطئة. ولتحقيق ذلك، يحقن وسم <script> مضمّنًا عبر React.createElement:

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 هو خطّاف Next.js يحقن HTML في تدفّق التصيير من الخادم SSR خارج شجرة مكوّنات React. ينتهي السكربت داخل HTML لكن React لا «يراه» أبدًا أثناء التصيير على العميل — ومن ثمّ لا تحذير. وعند دمجه مع مخزن Zustand لإدارة حالة السمة التفاعلية، نحصل على بديل كامل بصفر تبعيات.

كيف يعمل

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

يدير المخزن حالة السمة، ويطبّق الأصناف على 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

يقوم المزوّد بأمرين: حقن سكربت منع الوميض FOUC عبر useServerInsertedHTML، وتهيئة مخزن Zustand عند التركيب:

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

الواجهة البرمجية متطابقة عن قصد. الانتقال هو تغيير استيراد واحد لكل ملف:

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، لكن بلا تاريخ إصدار، وبلا سجل تغييرات، وبلا إشارة واضحة إلى أن تحذير React 19 قد أُصلِح. المراهنة بكود الإنتاج على نسخة بيتا مفتوحة الأجل ليست مخاطرة تستحق العناء.

بـ CSS فقط (prefers-color-scheme)

يصلح لكشف سمة النظام، لكنه لا يستطيع التعامل مع استمرار تفضيل المستخدم (localStorage)، ولا التبديل اليدوي للسمة، ولا خيار «system». تحتاج إلى JavaScript لذلك.

الخلاصات

  1. next-themes متروك فعليًا — آخر إصدار في مارس 2025، وتحذير React 19 لم يُصلَح
  2. useServerInsertedHTML هو العنصر البدائي الصحيح في Next.js لحقن السكربتات دون تحذيرات React
  3. يوفّر Zustand حالة سمة تفاعلية بكود أقل من مزوّد Context
  4. الحل بأكمله نحو 100 سطر، صفر تبعيات جديدة، وأنت تملك كل سطر

المصادر