Hầu hết mọi người implement dark mode theo cách này: đặt theme vào localStorage, đọc nó bằng useEffect khi component mount, rồi cập nhật state. Nghe có vẻ ổn — cho đến khi bạn deploy lên production và nhận ra trang web bị nháy trắng một cái mỗi lần load. Người dùng đã chọn dark mode, nhưng trang vẫn render sáng trước, rồi mới tối sau khi JavaScript chạy xong.
Đây là vấn đề kinh điển của dark mode trong các SSR framework như Next.js: FOUC (Flash of Unstyled Content), hay cụ thể hơn là flash of wrong theme.
Bài viết này ghi lại cách tôi xây dựng lại theme toggle theo hướng “clean” hơn, tận dụng đúng cách hoạt động của Next.js App Router — server đọc cookie, render <html class="dark"> ngay từ đầu, client chỉ việc sync theo.
Vấn đề với useEffect + localStorage
Cách phổ biến nhất mà bạn sẽ thấy trên mạng:
// ❌ Cách này gây ra flash
const [theme, setTheme] = useState('light')
useEffect(() => {
const saved = localStorage.getItem('theme')
if (saved) setTheme(saved)
}, [])
Vấn đề ở đây là useEffect chỉ chạy phía client, sau khi render. Nghĩa là:
Server render HTML với theme mặc định (thường là light). HTML được gửi về trình duyệt, người dùng thấy giao diện sáng trong chốc lát. JavaScript load xong, useEffect chạy, đọc localStorage, cập nhật theme. Giao diện chuyển sang tối — và người dùng thấy cái nháy đó. Với cookies, server có thể đọc giá trị trước khi render, nên <html> đã có đúng class ngay từ đầu, không cần JavaScript can thiệp.
Các khái niệm cần biết
Trước khi đi vào code, có một vài hook của React mà implementation này dùng đến:
useCallback — Ghi nhớ (memoize) một function qua các lần re-render. Nếu không có nó, function setTheme sẽ được tạo mới mỗi lần ThemeProvider re-render, khiến tất cả component đang dùng nó cũng re-render theo dù không có gì thay đổi.
useMemo — Ghi nhớ một giá trị tính toán. Ở đây dùng để tạo object value cho Context — nếu không memoize, object này sẽ là một reference mới mỗi lần render, khiến toàn bộ cây component đang consume context bị re-render không cần thiết.
useTransition — Đánh dấu một state update là “không khẩn cấp”. Khi bạn gọi startTransition(async () => { ... }), React biết đây là tác vụ có thể trì hoãn (như lưu cookie lên server), nên UI không bị block trong lúc chờ. isPending cho biết transition đang chạy hay không — dùng để disable nút toggle.
Tại sao không dùng useEffect?
useEffect thường được dùng để sync state với side effects bên ngoài React (DOM, localStorage, network…). Nhưng ở đây chúng ta không cần “react theo” sự thay đổi — chúng ta đang chủ động trigger một hành động (lưu cookie) khi người dùng bấm nút. useTransition phù hợp hơn vì nó gắn trực tiếp vào event handler, không cần dependency array, không có nguy cơ chạy sai thứ tự.
Kiến trúc tổng thể
Toàn bộ flow chỉ gồm 4 phần, phân chia rõ ràng theo trách nhiệm:
layout.tsx (Server Component)
└─ đọc cookie → render <html class="dark"> → truyền theme vào ThemeProvider
theme-provider.tsx (Client Component)
└─ giữ theme state, expose setTheme qua Context
theme-toggle.tsx (Client Component)
└─ consume Context, gọi setTheme khi bấm nút
theme-action.ts (Server Action)
└─ nhận theme từ client, ghi vào cookie
Server lo phần “hiển thị đúng ngay từ đầu”. Client lo phần “tương tác sau đó”.
Chi tiết từng file
1. theme-action.ts — Server Action ghi cookie
'use server'
import { cookies } from 'next/headers'
export type Theme = 'light' | 'dark'
export async function setThemePreference(theme: Theme) {
const cookieStore = await cookies()
cookieStore.set('theme', theme, {
path: '/',
maxAge: 60 * 60 * 24 * 365, // 1 năm
sameSite: 'lax',
})
}
Đây là Server Action — function chạy trên server nhưng có thể được gọi từ client như một async function bình thường. Không cần tạo API route riêng. 'use server' ở đầu file là đủ để Next.js biết phải xử lý nó phía server.
2. layout.tsx — Server Component đọc cookie
export default async function RootLayout({ children }) {
const cookieStore = await cookies()
const themeCookie = cookieStore.get('theme')?.value
const theme: Theme = themeCookie === 'dark' ? 'dark' : 'light'
return (
<html className={theme === 'dark' ? `${fontClassName} dark` : fontClassName}>
<body>
<ThemeProvider initialTheme={theme}>
{children}
</ThemeProvider>
</body>
</html>
)
}
Đây là điểm mấu chốt. layout.tsx là Server Component, nên nó có thể đọc cookie trước khi render. Class dark đã có trên <html> ngay trong HTML trả về từ server — không cần JavaScript, không có flash.
3. theme-provider.tsx — Context + state quản lý theme
'use client'
export function ThemeProvider({ children, initialTheme }) {
const [theme, setThemeState] = useState(initialTheme)
const [isPending, startTransition] = useTransition()
// useCallback memoize setTheme để tránh re-render cascade
const setTheme = useCallback((nextTheme: Theme) => {
// 1. Cập nhật UI ngay lập tức
setThemeState(nextTheme)
syncDocumentTheme(nextTheme)
// 2. Lưu cookie trong background (không block UI)
startTransition(async () => {
try {
await setThemePreference(nextTheme)
} catch {
// Nếu lưu thất bại, rollback về theme cũ
setThemeState(theme)
syncDocumentTheme(theme)
}
})
}, [theme])
const value = useMemo(
() => ({ theme, setTheme, isPending }),
[isPending, setTheme, theme],
)
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
}
Mỗi lần ThemeProvider re-render, nếu không có useCallback, JavaScript sẽ tạo ra một object function mới cho setTheme — dù logic bên trong hoàn toàn giống nhau.
Object mới → reference khác → useMemo ở dưới nghĩ có gì đó thay đổi → tạo context value mới → toàn bộ component đang consume context bị re-render theo.
useCallback ngăn điều đó: nó trả về cùng một function reference qua các lần render, chỉ tạo function mới khi theme trong dependency array thay đổi.
Tại sao dependency là [theme] mà không phải rỗng []? Vì trong catch block, code cần đọc giá trị theme tại thời điểm lỗi xảy ra để rollback đúng. Nếu dependency rỗng, function sẽ bị "đóng băng" (stale closure) với theme từ lần render đầu tiên — nếu người dùng đã toggle vài lần trước khi lỗi xảy ra, rollback sẽ nhảy về sai giá trị.
Sau đó useMemo đóng gói context value thành một object ổn định.
Nếu bạn viết thẳng value={{ theme, setTheme, isPending }} vào JSX, mỗi lần ThemeProvider render sẽ tạo ra một object literal mới — dù ba giá trị bên trong không đổi. React so sánh value bằng reference equality (===), nên object mới → context thay đổi → mọi consumer re-render.
useMemo giữ nguyên reference của object đó, chỉ tạo mới khi ít nhất một trong ba giá trị thực sự thay đổi. Kết hợp với useCallback ở trên — setTheme chỉ thay đổi reference khi theme thay đổi — nên trong phần lớn lifecycle của app, object này là bất biến.
Ngoài ra, Có một chi tiết đáng chú ý ở đây: syncDocumentTheme cập nhật DOM trực tiếp, không chờ React re-render. Điều này đảm bảo theme đổi ngay khi bấm nút, dù cookie chưa được lưu xong.
function syncDocumentTheme(theme: Theme) {
document.documentElement.classList.toggle('dark', theme === 'dark')
document.documentElement.style.colorScheme = theme
}
colorScheme không chỉ là cosmetic — nó báo cho browser biết để các built-in UI element (scrollbar, input, select…) cũng tự điều chỉnh theo theme.
4. theme-toggle.tsx — Component bấm nút
'use client'
export default function ThemeToggle() {
const { theme, setTheme, isPending } = useTheme()
const isDark = theme === 'dark'
return (
<button
onClick={() => setTheme(isDark ? 'light' : 'dark')}
disabled={isPending}
aria-label={`Switch to ${isDark ? 'light' : 'dark'} theme`}
>
{isDark ? <SunIcon /> : <MoonIcon />}
</button>
)
}
Component này rất gọn — không có logic nào, chỉ consume Context và render. isPending từ useTransition được dùng để disable nút trong lúc cookie đang được lưu, tránh người dùng click liên tục.
5. globals.css — Tailwind 4 dark variant
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
Tailwind 4 không còn config darkMode: 'class' trong tailwind.config.js nữa. Thay vào đó, dùng @custom-variant trong CSS để khai báo dark variant dựa trên class .dark trên element cha. Cú pháp :where(.dark, .dark *) nghĩa là “element này hoặc bất kỳ con nào của .dark”.
So sánh: useEffect vs useTransition
Cách dùng useEffect không sai về mặt kỹ thuật — nhưng nó không tận dụng được thế mạnh của SSR. Khi bạn đã có server, để server làm việc của nó.
Những lưu ý thực tế
Đừng đọc cookie ở Client Component. Nếu bạn cần theme ở client, hãy truyền nó xuống qua props hoặc Context từ Server Component — đúng như cách ThemeProvider nhận initialTheme. Đọc cookie phía client là có thể, nhưng lại quay về vấn đề FOUC ban đầu.
useCallback dependency array phải chính xác. Trong setTheme, dependency là [theme] vì callback cần giá trị theme hiện tại để rollback khi lỗi. Nếu bỏ dependency này, bạn sẽ gặp stale closure — theme trong catch sẽ luôn là giá trị lúc component mount, không phải giá trị hiện tại.
Đừng memoize quá mức. useCallback và useMemo chỉ có giá trị khi component thực sự re-render nhiều và việc re-render đó gây ra vấn đề. Với một ThemeProvider gần root, memoize là hợp lý vì nó ảnh hưởng đến toàn bộ cây. Ở các component nhỏ, leaf component, thường không cần thiết.
aria-label trên nút toggle là bắt buộc. Nút chỉ có icon, không có text — screen reader sẽ không biết nó làm gì nếu không có label.
Tóm tắt
Để có dark mode không bị flash trong Next.js App Router:
Lưu preference vào cookie (không phải localStorage) để server đọc được. Server Component đọc cookie, render <html class="dark"> trước khi HTML đến trình duyệt. Client Component nhận initialTheme qua props, giữ state nội bộ, sync DOM trực tiếp khi toggle. Server Action ghi cookie trong background bằng useTransition, không block UI. Những khái niệm đã dùng: (memoize function tránh re-render thừa), (memoize context value), useTransition (đánh dấu side effect không khẩn cấp, lấy isPending).