useSyncExternalStore giúp một component theo dõi (subscribe) dữ liệu nằm ngoài React và render lại mỗi khi dữ liệu đó thay đổi. Bạn truyền cho nó hai hàm: subscribe, cho React biết cách lắng nghe thay đổi, và getSnapshot, trả về giá trị hiện tại.
Hai component Display không dùng chung props hay context nào, vậy mà cả hai cùng cập nhật, vì cả hai đều subscribe vào cùng một store. Nút bấm gọi một hàm bình thường, không phải hàm setter của React. Thêm một <Display name="Sidebar" /> thứ ba và nó tham gia ngay mà không cần sửa gì khác.
Cú pháp
const value = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?);
subscribe(callback)bắt đầu lắng nghe, gọicallbackmỗi khi dữ liệu có thể đã thay đổi, và trả về một hàm hủy đăng ký. React gọi nó sau khi component được mount và gọi hàm được trả về khi unmount.getSnapshot()trả về giá trị hiện tại. React gọi nó trong mỗi lần render và sau mỗi lần được thông báo, rồi so sánh kết quả với lần trước bằngObject.is. Giá trị giống nhau thì không render.getServerSnapshot()(không bắt buộc) trả về giá trị dùng trên máy chủ và khi hydration.
Hãy định nghĩa subscribe bên ngoài component, hoặc giữ nó ổn định bằng useCallback. Nếu bạn truyền một hàm subscribe mới ở mỗi lần render, React sẽ hủy đăng ký rồi đăng ký lại mỗi lần như vậy.
API trình duyệt đóng vai trò store
Bất cứ thứ gì có một giá trị hiện tại và phát ra sự kiện khi giá trị đó thay đổi đều hợp với mô hình này. navigator.onLine cùng các sự kiện online và offline là ví dụ kinh điển.
Hãy tắt mạng (hoặc chọn Offline trong công cụ dành cho nhà phát triển của trình duyệt) và dòng chữ thay đổi mà không cần tải lại trang. Đối số thứ ba nói "cứ coi như đang online" khi render trên máy chủ, nơi không có navigator. Bọc lời gọi hook trong useOnlineStatus biến nó thành một custom hook mà component nào cũng dùng được.
Độ rộng cửa sổ cũng hoạt động theo cách tương tự:
Thêm vài bản sao rồi thay đổi kích thước cửa sổ: mọi bản sao đều hiển thị cùng một con số vào cùng một thời điểm. Mỗi bản sao có listener riêng, và mỗi bản đọc độ rộng ngay trong lúc render, nên không bản nào chậm hơn dù chỉ một khung hình.
getSnapshot phải trả về giá trị đã cache
React gọi getSnapshot thường xuyên và so sánh kết quả theo tham chiếu. Một hàm tạo object hoặc mảng mới ở mỗi lần gọi sẽ luôn trông như có thay đổi:
// Broken: a new object on every call
function getSnapshot() {
return { count: store.count, user: store.user };
}
// Also broken: filter returns a new array every time
function getSnapshot() {
return store.todos.filter((t) => !t.done);
}
React render, gọi getSnapshot, nhận một giá trị "khác", render lại, cứ thế tiếp diễn cho đến khi dừng với lỗi "Maximum update depth exceeded". Ở môi trường development, React còn ghi log "The result of getSnapshot should be cached to avoid an infinite loop" trước đó. Bản build production, như phần xem trước ở đây, bỏ qua cảnh báo đó và chỉ báo lỗi cuối cùng dưới dạng một mã ngắn (Minified React error #185).
Cách khắc phục là giữ dữ liệu trong store ở dạng bất biến (immutable): thay object mới khi dữ liệu thay đổi, và trả về nguyên tham chiếu đang lưu.
getSnapshot trả về cùng một object state cho đến khi add thay thế nó, nên component chỉ render một lần cho mỗi thay đổi. Việc lọc diễn ra trong component, sau khi đọc snapshot, và điều đó an toàn. Nếu muốn lọc ngay trong store, hãy tính mảng đã lọc khi dữ liệu thay đổi rồi lưu lại, để getSnapshot có thể trả về bản đã lưu đó.
getServerSnapshot và hydration
Trên máy chủ không có window, không có navigator và cũng không có subscription nào. Đối số thứ ba cho React biết cần render gì ở đó:
const width = useSyncExternalStore(
subscribe,
() => window.innerWidth, // in the browser
() => 1024 // on the server, and during hydration
);
React cũng dùng getServerSnapshot cho lần render đầu tiên trong trình duyệt khi hydrate HTML từ máy chủ, để hai bên khớp nhau. Ngay sau hydration, React đọc getSnapshot và, nếu giá trị thật khác đi, sẽ render lại với giá trị đó. Thiếu đối số thứ ba, lần render trên máy chủ sẽ ném lỗi "Missing getServerSnapshot, which is required for server-rendered content. Will revert to client rendering." Nếu có một boundary <Suspense> phía trên component, máy chủ gửi fallback của boundary đó và trình duyệt render phần nội dung thay thế; nếu không có boundary, lần render trên máy chủ thất bại.
useSyncExternalStore so với useEffect và useState
Bạn có thể subscribe bằng một effect:
function useOnlineStatus() {
const [online, setOnline] = useState(true);
useEffect(() => {
const update = () => setOnline(navigator.onLine);
update();
window.addEventListener('online', update);
window.addEventListener('offline', update);
return () => {
window.removeEventListener('online', update);
window.removeEventListener('offline', update);
};
}, []);
return online;
}
Cách này chạy được, nhưng có hai điểm yếu. Lần render đầu tiên luôn hiển thị giá trị đoán ban đầu, còn giá trị thật đến chậm một lần render, sau khi effect chạy. Ngoài ra, với concurrent rendering (chẳng hạn trong một transition), React có thể tạm dừng một lần render giữa chừng; nếu store thay đổi trong lúc tạm dừng, các component được render trước và sau đó có thể hiển thị các giá trị khác nhau. Sự không nhất quán này gọi là tearing. useSyncExternalStore đọc giá trị ngay trong lúc render và buộc React render lại một cách đồng bộ nếu store đã thay đổi, nên mọi component đều thấy cùng một giá trị.
Hãy dùng hook này khi dữ liệu nằm ngoài React: module store của riêng bạn, một API trình duyệt, một thư viện của bên thứ ba. Hầu hết thư viện quản lý state (Redux, Zustand và nhiều thư viện khác) đã gọi nó giúp bạn bên trong hook của chúng. Với dữ liệu thuộc về chính các component, useState, useReducer và context vẫn là những công cụ phù hợp.
Câu hỏi thường gặp
useSyncExternalStore dùng để làm gì?
Để đọc dữ liệu không do React quản lý và có thể tự thay đổi: một store viết bên ngoài React, một thư viện quản lý state của bên thứ ba, hoặc một giá trị của trình duyệt như navigator.onLine hay độ rộng cửa sổ. Component render lại mỗi khi store báo cho React biết nó đã thay đổi.
subscribe và getSnapshot làm gì?
subscribe(callback) bắt đầu lắng nghe store, gọi callback mỗi khi có thay đổi và trả về một hàm để ngừng lắng nghe. getSnapshot() trả về giá trị hiện tại. React gọi getSnapshot trong lúc render và sau mỗi lần được thông báo, rồi chỉ render lại khi giá trị thay đổi theo Object.is.
Vì sao getSnapshot phải trả về giá trị đã cache?
React so sánh kết quả của mỗi lần gọi getSnapshot với kết quả trước đó. Nếu hàm này trả về một object hoặc mảng mới mỗi lần, React luôn thấy có thay đổi, render lại, gọi getSnapshot lần nữa và lặp mãi cho đến khi ném lỗi "Maximum update depth exceeded". Hãy trả về cùng một tham chiếu cho đến khi dữ liệu thực sự thay đổi.
getServerSnapshot là gì?
Đối số thứ ba, không bắt buộc. Hàm này trả về giá trị dùng khi render trên máy chủ và khi hydration trong trình duyệt, để cả hai tạo ra cùng một HTML. Nếu thiếu nó, component ném lỗi "Missing getServerSnapshot" trên máy chủ, và nội dung bên dưới boundary <Suspense> gần nhất sẽ được render trong trình duyệt thay thế.
Nên dùng useSyncExternalStore hay useEffect kết hợp useState?
Để theo dõi dữ liệu bên ngoài, hãy ưu tiên useSyncExternalStore. Hook này đọc giá trị ngay trong lúc render, nên lần render đầu tiên đã đúng và mọi component đều thấy cùng một giá trị, kể cả khi concurrent rendering. useEffect với useState render một lần với giá trị cũ và có thể thoáng hiển thị các giá trị khác nhau ở các component khác nhau.