الوضع الداكن في Next.js دون وميض أو تحذيرات React 19
كيفية استبدال next-themes بـ Zustand مع useServerInsertedHTML للحصول على وضع داكن دون وميض في Next.js 15+، ودون إطلاق تحذيرات سكربت React 19.
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 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 هو خطّاف 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:
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 عند التركيب:
"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
الواجهة البرمجية متطابقة عن قصد. الانتقال هو تغيير استيراد واحد لكل ملف:
- 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، لكن بلا تاريخ إصدار، وبلا سجل تغييرات، وبلا إشارة واضحة إلى أن تحذير React 19 قد أُصلِح. المراهنة بكود الإنتاج على نسخة بيتا مفتوحة الأجل ليست مخاطرة تستحق العناء.
بـ CSS فقط (prefers-color-scheme)
يصلح لكشف سمة النظام، لكنه لا يستطيع التعامل مع استمرار تفضيل المستخدم (localStorage)، ولا التبديل اليدوي للسمة، ولا خيار «system». تحتاج إلى JavaScript لذلك.
الخلاصات
next-themesمتروك فعليًا — آخر إصدار في مارس 2025، وتحذير React 19 لم يُصلَحuseServerInsertedHTMLهو العنصر البدائي الصحيح في Next.js لحقن السكربتات دون تحذيرات React- يوفّر 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