Lỗi gặp phải
Error: Hydration failed because the initial UI does not match what was rendered on the server.
Warning: Expected server HTML to contain a matching <div> in <div>.
at div
at MyComponent
Bạn đẩy code lên production, mọi thứ trông ổn ở local, rồi phía client bắt đầu ném lỗi hydration — hoặc tệ hơn, âm thầm render nội dung cũ. Đây là cách truy tìm và xử lý triệt để.
Tại sao lỗi này xảy ra
React render component trên server và gửi HTML về trình duyệt. Sau đó nó "hydrate" — gắn các event handler vào DOM hiện có. Nếu DOM mà React nhận được không khớp với những gì nó tự render, quá trình hydration sẽ thất bại.
Chín trong mười trường hợp, thủ phạm là một trong những nguyên nhân sau:
- Các API chỉ có ở browser (
window,localStorage,navigator) được truy cập trong quá trình render - Ngày giờ hoặc timestamp được render ở hai thời điểm khác nhau giữa server và client
- Render có điều kiện dựa trên
typeof window !== 'undefined' - Extension trình duyệt tự ý chèn node vào DOM (trình chặn quảng cáo, trình quản lý mật khẩu, công cụ dịch)
- HTML lồng nhau không hợp lệ —
<div>bên trong<p>, hoặc<p>bên trong<p> - Component bên thứ ba không tương thích với SSR
Cách khắc phục từng bước
Bước 1 — Xác định chính xác điểm không khớp
Khởi động dev server. Mở console trình duyệt. Tìm thông báo lỗi hydration đầy đủ — React 18 in ra chính xác điểm khác biệt:
pnpm dev
# hoặc
npm run dev
Đọc kỹ toàn bộ thông báo lỗi trước khi làm bất cứ điều gì. Nó chỉ rõ tên component và element. Chín trong mười trường hợp bạn có thể nhảy thẳng đến đúng file ngay lập tức.
Bước 2 — Sửa code chỉ chạy được ở browser trong render
Truy cập window hoặc localStorage trong quá trình render là nguyên nhân phổ biến nhất. Server không có các API này. Nó crash âm thầm hoặc trả về undefined, và kết quả không khớp với phía client.
Sai:
// localStorage không tồn tại trên server — đoạn này làm hỏng SSR
export default function ThemeToggle() {
const theme = localStorage.getItem('theme') || 'light';
return <button>{theme}</button>;
}
Đúng — chuyển vào useEffect:
import { useState, useEffect } from 'react';
export default function ThemeToggle() {
const [theme, setTheme] = useState('light'); // giá trị mặc định an toàn cho SSR
useEffect(() => {
const saved = localStorage.getItem('theme');
if (saved) setTheme(saved);
}, []);
return <button>{theme}</button>;
}
useEffect không bao giờ chạy trên server. Cả server lẫn lần render đầu tiên ở client đều đồng thuận với giá trị 'light'. Client sau đó đồng bộ về giá trị thực sau khi hydration hoàn tất.
Bước 3 — Sửa render ngày giờ
Timestamp là cái bẫy kinh điển. Server render ở một thời điểm, client render vài mili-giây sau — kết quả khác nhau, hydration thất bại.
// Sai — new Date() trên server != new Date() trên client
<span>{new Date().toLocaleString()}</span>
// Đúng — chỉ render ngày giờ ở phía client
function ClientDate() {
const [date, setDate] = useState('');
useEffect(() => {
setDate(new Date().toLocaleString());
}, []);
return <span>{date}</span>;
}
Chuỗi rỗng trong lần render đầu tiên, timestamp thực sau khi mount. Không có sự không khớp.
Bước 4 — Dùng dynamic import cho component không tương thích SSR
Một số component đơn giản là không thể chạy phía server — thư viện bản đồ, biểu đồ canvas, component gọi browser API ngay lúc import. Đừng cố ép. Bỏ qua SSR hoàn toàn cho những trường hợp đó:
import dynamic from 'next/dynamic';
const MapComponent = dynamic(() => import('../components/Map'), {
ssr: false,
loading: () => <p>Đang tải bản đồ...</p>,
});
export default function Page() {
return <MapComponent />;
}
Server gửi placeholder loading. Component thực chỉ tải sau khi JavaScript chạy trên trình duyệt. Tách biệt rõ ràng, không có xung đột hydration.
Bước 5 — Sửa HTML lồng nhau không hợp lệ
Trình duyệt tự động sửa HTML sai mà không báo lỗi. React thì không. Khoảng cách đó gây ra sự không khớp trông có vẻ hoàn toàn không liên quan đến vấn đề thực sự.
// Sai — block element bên trong <p> là HTML không hợp lệ
<p>
<div>Nội dung nào đó</div>
</p>
// Đúng
<div>
<div>Nội dung nào đó</div>
</div>
Chạy trang của bạn qua W3C Validator nếu bạn nghi ngờ có vấn đề về cấu trúc. Công cụ này phát hiện lỗi lồng nhau ngay lập tức.
Bước 6 — Xử lý render có điều kiện đúng cách
Phân nhánh dựa trên typeof window trong quá trình render chắc chắn gây ra sự không khớp. Server thấy undefined, client thấy đối tượng window thực — chúng tạo ra HTML khác nhau.
// Sai
function Banner() {
if (typeof window === 'undefined') return null;
return <div>Banner chỉ hiện ở client</div>;
}
// Đúng — dùng flag mounted
function Banner() {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) return null;
return <div>Banner chỉ hiện ở client</div>;
}
Bước 7 — Bỏ qua cảnh báo với những trường hợp không tránh khỏi
Extension trình duyệt là nhân tố khó lường. Trình quản lý mật khẩu, trình chặn quảng cáo, và công cụ dịch chèn DOM node mà bạn không thể kiểm soát. Với những trường hợp đó, hãy tắt cảnh báo ở cấp container:
<div suppressHydrationWarning={true}>
{content}
</div>
Lưu ý: thuộc tính này chỉ tắt cảnh báo ở một cấp, và không có tác dụng gì để sửa bug thực sự trong code của bạn. Chỉ dùng cho những thay đổi DOM bên ngoài hợp lệ — không phải để im lặng các lỗi chưa được debug.
Kiểm tra sau khi sửa
- Mở DevTools Console — không còn cảnh báo hydration khi tải trang nghĩa là bạn đã xong.
- Tắt JavaScript và tải lại. HTML được server render vẫn phải đọc được và đúng cấu trúc.
- Chạy production build:
pnpm build && pnpm start
Chế độ production làm lộ ra các sự không khớp hydration mà chế độ dev đôi khi che khuất. Luôn kiểm tra production build trước khi triển khai.
- Kiểm tra React DevTools Profiler — không có remount bất ngờ trong quá trình render cây component ban đầu.
## Duy trì sạch sẽ lâu dài
- **Khởi tạo state cho server, không phải cho người dùng.** Giá trị mặc định trong `useState` phải an toàn cho SSR. Bất cứ thứ gì liên quan đến người dùng cụ thể — tùy chọn giao diện, trạng thái xác thực, locale — phải nằm trong `useEffect`.
- **Kiểm tra package bên thứ ba trước khi cài.** Bất kỳ thư viện nào đụng vào `window` hoặc `document` lúc import đều cần `dynamic(..., { ssr: false })`. Kiểm tra README hoặc GitHub Issues của package — thường đã có người gặp vấn đề này rồi.
- **Dùng cookie, không dùng localStorage, cho nội dung SSR cá nhân hóa.** Trạng thái đăng nhập, tùy chọn locale, hoặc biến thể A/B test được render từ `localStorage` sẽ gây không khớp. Thay vào đó hãy truyền chúng phía server thông qua cookie qua `next/headers`.
- **Kiểm tra trên browser profile sạch.** Browser cá nhân của bạn với đầy đủ extension sẽ cho kết quả false positive. Hãy giữ một Chrome profile trống dùng riêng để kiểm tra hydration.

