Tìm hiểu về lỗi React Hydration Mismatch và Server Components

Table of Contents
Các Server Component và kiến trúc hydration của React đại diện cho một trong những thay đổi quan trọng nhất trong phát triển web frontend trong thập kỷ qua. Bằng cách tách biệt render phía server khỏi tương tác phía client, các framework hiện đại như Next.js cung cấp HTML tĩnh ngay lập tức trong khi truyền tải và hydrate logic client theo yêu cầu.
Tuy nhiên, kiến trúc lai này lại gây ra một loại lỗi runtime tinh vi và khó chịu: lỗi không khớp hydration. Khi cây DOM được tạo trên server khác biệt dù chỉ một node văn bản hoặc thuộc tính so với những gì client tạo ra trong quá trình đối chiếu ban đầu, React sẽ dừng hydration, cảnh báo mạnh mẽ trong môi trường phát triển và quay lại việc render lại phía client tốn kém.
Lỗi không khớp Hydration là gì?
Lỗi không khớp hydration xảy ra khi chuỗi HTML được render phía server khác với cây Virtual DOM được tạo ra trong lần render đầu tiên của React trong trình duyệt. React mong đợi DOM trên màn hình phải là một biểu diễn chính xác của trạng thái component client tại thời điểm mount.
Trong phần tìm hiểu sâu này, chúng ta sẽ phân tích cách React đối chiếu markup của server, phân tích năm nguyên nhân gốc rễ phổ biến nhất với các ví dụ mã có thể tái tạo, và triển khai bốn mẫu đã được kiểm nghiệm để đảm bảo hydration không lỗi trên React 18, React 19 và Next.js App Router.
Giải phẫu Hydration: Cách React tiếp quản HTML tĩnh
Để hiểu tại sao lỗi không khớp xảy ra, bạn phải hình dung những gì trình duyệt và runtime của React thực thi trong quá trình tải trang.
Giai đoạn 1: Render phía Server (SSR & RSC)
Trên server, Next.js thực thi cây component React của bạn. Server Component render thành một luồng trung gian nhỏ gọn được gọi là RSC Payload (một mô tả tuần tự hóa giống JSON của các phần tử và props JSX). Client Component ('use client') phát ra cả phần tử HTML và siêu dữ liệu tham chiếu các gói JS client. Server tập hợp điều này thành một tài liệu HTML hoàn chỉnh, hợp lệ và truyền tải nó đến người dùng.
Giai đoạn 2: First Contentful Paint (FCP)
Trình duyệt tải xuống payload HTML và phân tích cú pháp ngay lập tức. Người dùng nhìn thấy một trang trực quan được render hoàn chỉnh trong vòng vài mili giây. Tại thời điểm này, các nút và biểu mẫu chỉ mang tính trang trí—chưa có trình lắng nghe sự kiện JavaScript nào được đính kèm.
Giai đoạn 3: Đối chiếu Hydration
Trình duyệt tải xuống các gói JavaScript. React mount ứng dụng, xây dựng một cây Virtual DOM trong bộ nhớ từ gốc. Thay vì tạo các node DOM mới bằng document.createElement(), React duyệt qua cây DOM hiện có và so sánh từng node với Virtual DOM mới được tính toán:
- Kiểm tra khớp thẻ:
<div id="profile">có khớp với<div id="profile">không? - Kiểm tra khớp thuộc tính:
className,hrefvàstylecó khớp không? - Kiểm tra nội dung văn bản:
"Welcome back, Guest"có khớp với"Welcome back, John"không? - Đính kèm trình lắng nghe: React liên kết
onClick,onChangevà các ủy quyền sự kiện tổng hợp với các phần tử DOM khớp.
Server Response (HTML):
<div>
<span>Welcome</span>
<time>08:00 AM UTC</time> <-- Generated on Server
</div>
Client Initial Render (VDOM):
<div>
<span>Welcome</span>
<time>01:00 AM PST</time> <-- Computed in Browser (User Local Timezone)
</div>
RESULT: Hydration Mismatch Error!
React Warning: Text content did not match. Server: "08:00 AM UTC" Client: "01:00 AM PST"
Nếu các cây khớp hoàn toàn, hydration hoàn tất một cách lặng lẽ trong một tick duy nhất. Nếu phát hiện lỗi không khớp, React sẽ ghi lại một lỗi mô tả trong môi trường phát triển (Hydration failed because the initial UI does not match what was rendered on the server). Trong React 18+, React cố gắng thực hiện một lượt phục hồi client, thay thế node DOM server không khớp bằng đầu ra được render phía client, gây ra các thay đổi bố cục (CLS) và lãng phí chu kỳ CPU.
Năm nguyên nhân gốc rễ của lỗi không khớp Hydration (kèm mã)
Lỗi hydration thuộc năm loại riêng biệt. Chúng ta hãy xem xét từng loại với mã và giải pháp.
1. Các biến toàn cục chỉ có trong trình duyệt trong quá trình Render
Truy cập window, document, localStorage, hoặc navigator bên trong phần thân render là nguyên nhân phổ biến nhất gây ra lỗi hydration.
// ❌ ANTI-PATTERN: Direct browser API access in render
export function UserGreeting() {
// On the server, typeof window is "undefined" -> returns "Guest"
// On the client, localStorage has a token -> returns "Alex"
const username = typeof window !== 'undefined'
? localStorage.getItem('user_name') || 'Guest'
: 'Guest';
return <h1>Welcome back, {username}!</h1>;
}
Trong quá trình render phía server, username đánh giá thành "Guest". Trình duyệt render <h1>Welcome back, Guest!</h1>. Khi JavaScript phía client chạy, typeof window !== 'undefined' đánh giá thành true, kéo "Alex" từ localStorage. Virtual DOM của React mong đợi <h1>Welcome back, Alex!</h1>, xung đột với HTML của server.
2. Định dạng phụ thuộc vào múi giờ và ngôn ngữ
Render ngày, giờ hoặc tiền tệ mà không có tham số múi giờ cố định sẽ gây ra lỗi không khớp bất cứ khi nào máy chủ và khách truy cập nằm ở các khu vực địa lý khác nhau.
// ❌ ANTI-PATTERN: Unpinned date and time formatting
export function OrderTimestamp({ timestamp }: { timestamp: number }) {
// Server in Virginia (US East): "9/22/2026, 4:00:00 AM"
// User in Tokyo (JST): "2026/9/22 17:00:00"
const formatted = new Date(timestamp).toLocaleString();
return <span className="text-gray-500">{formatted}</span>;
}
Để loại bỏ sự khác biệt về ngôn ngữ, hãy định dạng với các chuỗi ngôn ngữ rõ ràng và múi giờ UTC:
// ✅ SOLUTION: Explicit locale and timeZone enforcement
export function OrderTimestamp({ timestamp }: { timestamp: number }) {
const formatted = new Intl.DateTimeFormat('en-US', {
dateStyle: 'medium',
timeStyle: 'short',
timeZone: 'UTC',
}).format(new Date(timestamp));
return <span className="text-gray-500">{formatted} UTC</span>;
}
3. Giá trị không xác định (Math.random, Date.now, UUID)
Tạo các định danh hoặc dấu thời gian ngẫu nhiên trong giai đoạn render đảm bảo rằng các giá trị của server và client sẽ khác nhau.
// ❌ ANTI-PATTERN: Generating random IDs in component scope
export function InputField({ label }: { label: string }) {
const id = `input-${Math.random().toString(36).slice(2, 9)}`;
return (
<div>
<label htmlFor={id}>{label}</label>
<input id={id} type="text" />
</div>
);
}
React 18 cung cấp useId() đặc biệt để giải quyết vấn đề này. useId() tạo ra một định danh ổn định, xác định, giống hệt nhau trên cả SSR và hydration client:
// ✅ SOLUTION: Deterministic ID generation with useId()
import { useId } from 'react';
export function InputField({ label }: { label: string }) {
const id = useId();
return (
<div>
<label htmlFor={id}>{label}</label>
<input id={id} type="text" />
</div>
);
}
4. Lồng ghép không hợp lệ theo đặc tả HTML
Các trình duyệt web có một đặc tả phân tích cú pháp HTML cổ xưa, dễ tha thứ. Nếu một nhà phát triển web viết lồng ghép HTML không hợp lệ, trình phân tích cú pháp gốc của trình duyệt sẽ tự động viết lại và sắp xếp lại cây DOM trước khi gói JavaScript của React thậm chí còn tải.
Các quy tắc lồng ghép HTML không hợp lệ phổ biến:
- Đặt một
<div>bên trong một thẻ<p>: Trình duyệt tự động đóng<p>ngay trước<div>, tạo ra các node<p></p><div>...</div><p></p>anh em. - Đặt một thẻ
<a>bên trong một thẻ<a>khác. - Bỏ qua
<tbody>bên trong một<table>: Trình duyệt chèn một phần tử<tbody>tổng hợp vào DOM. - Đặt
<ul>hoặc<li>bên ngoài các container danh sách.
// ❌ ANTI-PATTERN: Invalid HTML nesting
export function ArticleSnippet() {
return (
<p>
React is a declarative UI library.
{/* <div> inside <p> is forbidden by HTML spec */}
<div className="callout">Note: Version 19 is out!</div>
</p>
);
}
Khi React cố gắng hydrate, nó mong đợi <p> chứa <div>. Nhưng DOM của trình duyệt có <p>React is...</p><div class="callout">...</div>. React không thể đối chiếu cây và ngay lập tức ném ra lỗi không khớp hydration.
5. Tiện ích mở rộng trình duyệt của bên thứ ba và các tập lệnh được chèn
Các tiện ích mở rộng như Grammarly, Google Translate, LastPass hoặc Dark Reader chèn các thuộc tính (data-new-gr-c-s-check-loaded, spellcheck="false") hoặc chèn các phần tử bao bọc trực tiếp vào <body> hoặc các trường nhập liệu trước khi React chạy.
Mặc dù bạn không thể ngăn người dùng cài đặt tiện ích mở rộng, bạn có thể ngăn chặn các lỗi không khớp do tiện ích mở rộng gây ra bằng cách áp dụng suppressHydrationWarning trên các thẻ bố cục gốc:
// In app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<body suppressHydrationWarning className="min-h-screen bg-background">
{children}
</body>
</html>
);
}
suppressHydrationWarning hướng dẫn React không cảnh báo về các thuộc tính không khớp trên node DOM cụ thể đó. Nó chỉ áp dụng một cấp độ sâu và không ngăn chặn các lỗi không khớp trên các phần tử con.
Bốn mẫu kiến trúc sản xuất để loại bỏ lỗi không khớp
Tùy thuộc vào trường hợp sử dụng của bạn, hãy chọn mẫu đảm bảo hydration sạch mà không làm giảm trải nghiệm người dùng.
Mẫu 1: Mẫu Mount hai lượt (hasMounted)
Khi một component phụ thuộc cố hữu vào trạng thái chỉ có ở client (chẳng hạn như kiểm tra chiều rộng cửa sổ hoặc render tùy chọn người dùng từ localStorage), hãy trì hoãn việc render markup dành riêng cho client cho đến khi quá trình hydration ban đầu hoàn tất.
// components/ClientOnly.tsx
'use client';
import { useState, useEffect } from 'react';
interface ClientOnlyProps {
children: React.ReactNode;
fallback?: React.ReactNode;
}
export function ClientOnly({ children, fallback = null }: ClientOnlyProps) {
const [hasMounted, setHasMounted] = useState(false);
useEffect(() => {
setHasMounted(true);
}, []);
if (!hasMounted) {
return <>{fallback}</>;
}
return <>{children}</>;
}
Cách sử dụng trong một component:
export function NavigationProfile() {
return (
<ClientOnly fallback={<div className="h-8 w-24 bg-gray-200 animate-pulse rounded" />}>
<UserAccountDropdown />
</ClientOnly>
);
}
Cách hoạt động: Trong quá trình SSR, hasMounted là false, vì vậy server phát ra khung fallback. Trong quá trình hydration client ban đầu, hasMounted vẫn là false, khớp hoàn hảo với HTML của server. Trong microtask useEffect tiếp theo, setHasMounted(true) kích hoạt render lại, mount component tương tác mà không có cảnh báo lỗi không khớp nào.
Mẫu 2: Đồng bộ hóa trạng thái theo kiểu thông thường qua useSyncExternalStore
Mặc dù render hai lượt hoạt động, nhưng nó giới thiệu một chu kỳ render bổ sung và khả năng thay đổi bố cục. Đối với trạng thái trình duyệt như trạng thái trực tuyến, truy vấn phương tiện hoặc bộ nhớ cục bộ, useSyncExternalStore của React 18 là tiêu chuẩn chuyên nghiệp:
// hooks/useOnlineStatus.ts
'use client';
import { useSyncExternalStore } from 'react';
function subscribe(callback: () => void) {
window.addEventListener('online', callback);
window.addEventListener('offline', callback);
return () => {
window.removeEventListener('online', callback);
window.removeEventListener('offline', callback);
};
}
export function useOnlineStatus() {
return useSyncExternalStore(
subscribe,
() => navigator.onLine, // Client snapshot
() => true // Server snapshot (deterministic fallback)
);
}
export function StatusBadge() {
const isOnline = useOnlineStatus();
return (
<span className={isOnline ? 'text-emerald-500' : 'text-rose-500'}>
{isOnline ? 'System Online' : 'Offline Mode'}
</span>
);
}
useSyncExternalStore tách biệt rõ ràng ảnh chụp nhanh của server khỏi đăng ký của client, tránh các điều kiện tranh chấp và sự phân kỳ hydration một cách sạch sẽ.
Mẫu 3: Nhập client động với { ssr: false }
Nếu toàn bộ một component nặng phụ thuộc vào canvas, WebGL, bản đồ Leaflet hoặc API âm thanh của trình duyệt, việc render nó trên server là lãng phí. Sử dụng nhập động của Next.js với ssr: false:
// components/AnalyticsChartWrapper.tsx
import dynamic from 'next/dynamic';
const HeavyChart = dynamic(
() => import('@/components/HeavyChart').then((mod) => mod.HeavyChart),
{
ssr: false,
loading: () => <div className="h-64 w-full bg-muted animate-pulse rounded-lg" />,
}
);
export function Dashboard() {
return (
<div className="space-y-6">
<h2>Performance Metrics</h2>
<HeavyChart />
</div>
);
}
Với ssr: false, Next.js hoàn toàn bỏ qua việc render HeavyChart trên server, chèn component loading vào HTML và tải biểu đồ thực sự chỉ trên client.
Mẫu 4: suppressHydrationWarning cụ thể theo mục tiêu
Khi nội dung động như dấu thời gian tương đối ("3 phút trước") hoặc giá được bản địa hóa không thể được tính toán trước tại thời điểm build, hãy áp dụng suppressHydrationWarning trực tiếp vào node văn bản lá:
export function RelativeTime({ date }: { date: string }) {
// Format relative timestamp
const relative = formatTimeAgo(new Date(date));
return (
<time dateTime={date} suppressHydrationWarning>
{relative}
</time>
);
}
Phạm vi của suppressHydrationWarning
Luôn áp dụng suppressHydrationWarning cho phần tử thấp nhất có thể trong cây DOM (ví dụ: <time>, <span>). Đặt nó trên một <div> cha sẽ ngăn chặn các cảnh báo hydration cho tất cả các phần tử con, khiến bạn không nhìn thấy các lỗi markup thực tế.
Server Component so với Client Component: Ranh giới tuần tự hóa
Việc giới thiệu React Server Component (RSC) loại bỏ toàn bộ một loại vấn đề hydration vì Server Component không hydrate.
Component Architecture:
┌──────────────────────────────────────────────┐
│ Server Component (BlogPage) │
│ - Fetches from PostgreSQL database directly │
│ - Zero client JS emitted │
│ - NEVER HYDRATES (Zero mismatch risk!) │
│ │
│ ┌────────────────────────────────────────┐ │
│ │ Client Component ('use client') │ │
│ │ - Interactive Like Button │ │
│ │ - Hydrates event listeners │ │
│ │ - Must maintain HTML consistency │ │
│ └────────────────────────────────────────┘ │
└──────────────────────────────────────────────┘
Vì Server Component thực thi nghiêm ngặt trong runtime Node.js/Edge và truyền tải các payload HTML/RSC được tuần tự hóa, chúng không thể gây ra lỗi hydration. Hydration xảy ra độc quyền tại các ranh giới của các component được đánh dấu bằng 'use client'.
Quy tắc vàng của việc xen kẽ
Bạn có thể render Server Component bên trong Client Component bằng cách truyền chúng qua children:
// app/components/Modal.tsx ('use client')
'use client';
import { useState } from 'react';
export function Modal({ children }: { children: React.ReactNode }) {
const [isOpen, setIsOpen] = useState(false);
return (
<div>
<button onClick={() => setIsOpen(true)}>Open Modal</button>
{isOpen && <div className="modal-overlay">{children}</div>}
</div>
);
}
// app/page.tsx (Server Component)
import { Modal } from './components/Modal';
import { DatabaseUserList } from './components/DatabaseUserList'; // Server Component!
export default async function Page() {
return (
<main>
<h1>Admin Portal</h1>
<Modal>
{/* DatabaseUserList runs on the server and passes static JSX to Modal */}
<DatabaseUserList />
</Modal>
</main>
);
}
Ở đây, DatabaseUserList chạy hoàn toàn trên server. Đầu ra được render của nó được truyền cho Modal dưới dạng các phần tử con JSX có thể tuần tự hóa, bảo toàn lợi ích không gói của RSC trong khi duy trì tương tác client trong shell modal.
Danh sách kiểm tra gỡ lỗi Hydration
Khi bạn gặp cảnh báo hydration trong quá trình phát triển, hãy thực hiện danh sách kiểm tra có hệ thống này:
| Kiểm tra | Bước kiểm tra | Khắc phục |
|---|---|---|
| 1. Phân cấp HTML | Kiểm tra console để tìm lỗi validateDOMNesting(...) | Thay thế các thẻ không hợp lệ (ví dụ: <div> bên trong <p>, <a> bên trong <a>). |
| 2. API trình duyệt | Tìm kiếm component để tìm window, document, localStorage, matchMedia | Di chuyển vào bên trong useEffect hoặc bao bọc bằng trình trợ giúp ClientOnly. |
| 3. Ngày & Giờ | Tìm kiếm toLocaleString(), Date.now(), hoặc các bộ định dạng tương đối | Ghim ngôn ngữ và timeZone: 'UTC' hoặc thêm suppressHydrationWarning. |
| 4. Tạo ID | Kiểm tra Math.random() hoặc các chuỗi bộ đếm thủ công | Thay thế bằng hook useId() tích hợp của React. |
| 5. Tiện ích mở rộng của bên thứ ba | Kiểm tra xem lỗi có biến mất trong chế độ Ẩn danh của Chrome (tiện ích mở rộng bị tắt) không | Thêm suppressHydrationWarning vào <html> và <body>. |
| 6. SSR có điều kiện | Xác minh xem các props được truyền cho 'use client' có khác nhau trong lần render server ban đầu không | Đảm bảo việc tìm nạp dữ liệu trả về payload ban đầu giống hệt nhau cho server & client. |
Kiểm tra kiến thức tương tác
Tóm tắt
Lỗi không khớp hydration không phải là lỗi ngẫu nhiên—chúng là các tín hiệu xác định rằng môi trường thực thi của server và client không đồng ý về trạng thái của giao diện.
Bằng cách thực thi ngữ nghĩa HTML hợp lệ, tận dụng useId(), quản lý các dependency chỉ có ở client với useSyncExternalStore hoặc mount hai lượt, và giữ logic nghiệp vụ bên trong Server Component, bạn loại bỏ hoàn toàn ma sát hydration, cung cấp cho người dùng các ứng dụng web tức thì, liền mạch.
Bạn cũng có thể thích
Free In-Browser Developer Tools
Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.
Related Articles

Lỗi Hydration trong Next.js: Cách sửa "Text Content Mismatch" & Error 418 (2026)
Hướng dẫn sửa triệt để lỗi Hydration failed (#418), Text content does not match server-rendered HTML, lỗi dark mode flash next-themes và localStorage trong Next.js.
Read more
Next.js 15 Server Actions vs Route Handlers: So sánh kiến trúc chuyên sâu
Nắm vững khi nào nên chọn Server Actions so với Route Handlers trong Next.js 15, đi sâu vào progressive enhancement, hành vi caching, giao thức RPC và các ranh giới bảo mật.
Read more
React 19: Server Actions & Cập nhật Optimistic (Không độ trễ)
Làm chủ useOptimistic và Server Actions với cơ chế rollback tự động khi có lỗi mạng. Bao gồm ví dụ code production, các mẫu transition, và biểu đồ tuần tự.
Read more