Chuyện gì đang xảy ra
Một boundary <Suspense> bao bọc một component render phía server. Trong quá trình hydration — quá trình React gắn event listener và đối chiếu HTML từ server với trạng thái phía client — có gì đó kích hoạt một state update trước khi boundary đó hoàn tất. React 18 phát hiện xung đột và bỏ qua việc hydrate subtree đó, chuyển sang render hoàn toàn phía client.
Lỗi đầy đủ trong console:
Error: This Suspense boundary received an update before it finished hydrating. This caused the boundary to switch to client rendering.
Fallback phía client gây ra hiện tượng nhấp nháy — thường 100–300ms nội dung trắng hoặc spinner trước khi trang ổn định. Crawler SEO thấy HTML fallback thay vì nội dung đã render. Lighthouse trừ điểm LCP và CLS của bạn. Không crash, nhưng đây là tín hiệu rõ ràng cho thấy có gì đó đã kích hoạt không đúng lúc.
Nguyên nhân thường gặp
- Đọc từ
localStoragehoặcsessionStoragetrong quá trình render — các API này không tồn tại trên server, nên bất kỳ đoạn code nào đọc chúng và set state sẽ kích hoạt khi mount, ngay lúc hydration đang chạy. - Script bên thứ ba (analytics, chat widget) gọi state setter thông qua callback toàn cục ngay khi trang tải.
- Thư viện fetch dữ liệu (SWR, React Query) được cấu hình refetch khi mount — setter kích hoạt trước khi boundary Suspense bao quanh hoàn tất hydration.
- Khởi tạo trạng thái theme hoặc auth — đọc
window.matchMediahoặc cookie ở lần render đầu tiên và đồng bộ vào state. - Concurrent mode transitions — một lời gọi
startTransitionhoặcuseTransitionbắt đầu trước khi boundary hoàn tất hydration.
Debug: tìm thứ gì đã kích hoạt update
Bắt đầu bằng cách xác định chính xác component nào đang kích hoạt update sớm. Một ErrorBoundary tạm thời với console logger sẽ giúp bạn tìm ra nhanh chóng:
// ErrorBoundary.jsx
import { Component } from 'react';
export class HydrationErrorBoundary extends Component {
componentDidCatch(error, info) {
console.error('Hydration error caught:', error);
console.error('Component stack:', info.componentStack);
}
render() {
return this.props.children;
}
}
Bọc Suspense boundary của bạn với nó tạm thời:
<HydrationErrorBoundary>
<Suspense fallback={<Spinner />}>
<YourComponent />
</Suspense>
</HydrationErrorBoundary>
componentStack sẽ chỉ thẳng vào thủ phạm. Trong Next.js, chú ý cảnh báo thứ hai xuất hiện cùng lỗi này: "A component suspended while responding to synchronous input." Hai cảnh báo này hầu như luôn xuất hiện cùng nhau và xác nhận boundary nào đang có vấn đề.
Giải pháp 1: trì hoãn state update bằng useEffect
Chuyển state phụ thuộc vào browser API vào trong useEffect. Thường chỉ vậy là đủ. useEffect không bao giờ chạy trong SSR và chỉ thực thi sau khi trình duyệt đã vẽ xong — lúc đó hydration đã hoàn tất.
// Trước — kích hoạt trong quá trình render, gây update sớm
const [theme, setTheme] = useState(
typeof window !== 'undefined'
? localStorage.getItem('theme') ?? 'light'
: 'light'
);
// Sau — chờ cho đến khi hydration hoàn tất
const [theme, setTheme] = useState('light');
useEffect(() => {
const saved = localStorage.getItem('theme');
if (saved) setTheme(saved);
}, []);
Khởi tạo bằng giá trị mặc định an toàn cho server ('light'), sau đó đồng bộ từ localStorage trong effect. Boundary Suspense hoàn tất hydration với giá trị mặc định. Effect chạy sau đó, gọn gàng, mà không can thiệp vào quá trình hydration.
Giải pháp 2: dùng startTransition cho update không khẩn cấp
Có fetch kích hoạt khi mount? Bọc setter trong startTransition đánh dấu nó là ưu tiên thấp. React 18 hoãn các update đó cho đến sau khi hydration hoàn tất.
import { startTransition, useState, useEffect } from 'react';
function DataComponent() {
const [data, setData] = useState(null);
useEffect(() => {
fetchData().then(result => {
startTransition(() => {
setData(result); // sẽ không làm gián đoạn quá trình hydration đang chạy
});
});
}, []);
return <div>{data}</div>;
}
Giải pháp 3: useDeferredValue cho giá trị dẫn xuất từ props
Thay đổi prop đang kích hoạt update? useDeferredValue báo cho React tiếp tục render giá trị cũ trong khi hydration hoàn tất, rồi áp dụng giá trị mới sau khi mọi thứ ổn định.
import { useDeferredValue } from 'react';
function SearchResults({ query }) {
const deferredQuery = useDeferredValue(query);
return (
<Suspense fallback={<Spinner />}>
<Results query={deferredQuery} />
</Suspense>
);
}
Giải pháp 4: thêm guard isMounted để render có điều kiện
Một số component đơn giản là không thể chạy trên server — chúng đọc window, thao tác DOM, hoặc gọi các API chỉ có trên trình duyệt. Chặn chúng bằng kiểm tra isMounted để subtree Suspense giữ im lặng trong quá trình hydration:
import { useState, useEffect } from 'react';
function ClientOnlyWidget({ children }) {
const [hasMounted, setHasMounted] = useState(false);
useEffect(() => {
setHasMounted(true);
}, []);
if (!hasMounted) return null;
return children;
}
// Cách dùng — Suspense boundary sẽ không nhận update từ subtree này trong quá trình hydration
<Suspense fallback={<Skeleton />}>
<ClientOnlyWidget>
<ThirdPartyWidget />
</ClientOnlyWidget>
</Suspense>
Giải pháp 5: Next.js — dùng dynamic import với ssr: false
Next.js có cách thoát gọn hơn cho các component chỉ chạy phía client. dynamic(..., { ssr: false }) loại component đó ra khỏi SSR hoàn toàn — không có HTML từ server, không có nỗ lực hydration, không có xung đột.
import dynamic from 'next/dynamic';
const ClientOnlyMap = dynamic(() => import('./Map'), {
ssr: false,
loading: () => <div>Loading map...</div>,
});
// Không có xung đột Suspense — component này không bao giờ được render phía server
export default function Page() {
return (
<div>
<ClientOnlyMap />
</div>
);
}
Kiểm tra sau khi sửa
- Mở Chrome DevTools → Console. Lỗi và các cảnh báo đi kèm phải biến mất.
- Chuyển sang tab Network, throttle về Slow 3G, và hard-reload. Quan sát xem có hiện tượng nội dung nhấp nháy hay spinner ngắn không — cả hai không được xuất hiện khi hydration đã sạch.
- Chạy
npx react-devtoolsvà kiểm tra tab Profiler. Hydration sạch sẽ cho thấy một phase "commit" duy nhất mà không có re-render bất ngờ bên trong subtree Suspense. - Trong Next.js, quét output terminal của
next build. Các trang hydrate sạch sẽ render dưới dạng○(static) hoặcλ(server) mà không có cảnh báo hydration trong log.
Bài học rút ra
Lỗi này đặc trưng cho React 18 vì concurrent rendering đã khiến hydration có thể bị gián đoạn. Đó chính là tính năng cung cấp sức mạnh cho Suspense trong việc fetch dữ liệu. Đánh đổi lại: React giờ nghiêm ngặt hơn về những gì có thể xảy ra trong khi một boundary đang hydrate.
Hydration hoạt động như một khóa. Một boundary <Suspense> giữ khóa đó trong khi hydrate. Bất kỳ state update nào đến trước khi khóa được giải phóng sẽ bị từ chối và buộc phải re-render phía client. useEffect, startTransition, và useDeferredValue đều hoạt động theo cùng cách — chúng chờ khóa được giải phóng trước khi chạy.
Đang xây dựng thư viện component hoặc design system? Kiểm tra mọi component đọc browser global trong quá trình render. Đó là những thứ đầu tiên cần xem xét khi lỗi này xuất hiện trong các ứng dụng sử dụng thư viện của bạn.

