State và data fetching — Context, store, TanStack Query, race condition, tearing
Câu hỏi kiểu tech lead: "Dự án mới, anh chọn quản lý state thế nào? Redux, Context hay Zustand?". Câu trả lời tốt không bắt đầu bằng tên thư viện, mà bằng phân loại state: phần lớn cái mà team gọi là "global state" thực ra là bản sao dữ liệu của server — tức là một cái cache, với đủ mọi bài toán của cache: cũ, trùng request, invalidate, và race condition. Bài này đi qua cơ chế của từng lựa chọn, đo số lần render thật, và các bẫy làm hiện sai dữ liệu.
Đã verify gì. Chạy thật trên React 19.3.0,@tanstack/react-query5.104.0,zustand5.0.15, Node 22.23.3, jsdom 26.1 (container tạm). API "server" trong lab là hàm giả có độ trễ cố định để tái hiện đúng thứ tự response. Giá trị mặc định của TanStack Query (gcTime,retry) đọc thẳng từ source trongnode_modules. Redux Toolkit, Jotai, SWR chưa dựng lab — so sánh dựa trên tài liệu. Lệnh ở phụ lục.
Bảng thuật ngữ
| Thuật ngữ | Nghĩa ngắn |
|---|---|
| server state | Dữ liệu mà nguồn thật nằm ở server (đơn hàng, sản phẩm). Ở client chỉ là bản sao có thể đã cũ. |
| client state | Dữ liệu chỉ tồn tại ở client (modal đang mở, tab đang chọn, giỏ hàng chưa lưu). |
| prop drilling | Truyền props qua nhiều tầng component không dùng tới nó, chỉ để đến được component sâu bên dưới. |
| selector | Hàm chọn một phần của store: s => s.cart. Component chỉ render lại khi phần đó đổi. |
| race condition | Kết quả phụ thuộc vào thứ tự các việc chạy song song hoàn thành — ở đây là thứ tự response về. |
| tearing (xé hình) | Cùng một lúc, các phần khác nhau của UI hiển thị các phiên bản khác nhau của cùng một dữ liệu. |
| stale-while-revalidate | Hiện dữ liệu cũ ngay, đồng thời lấy bản mới ở nền rồi cập nhật. |
| optimistic update | Cập nhật UI như thể server đã thành công, rồi hoàn tác nếu server báo lỗi. |
| dedupe (khử trùng) | Nhiều nơi cùng xin một dữ liệu cùng lúc thì chỉ gửi một request. |
Câu trả lời 60 giây
"Tôi chia state làm năm loại: UI cục bộ (useStatengay tại chỗ), URL (bộ lọc, trang — để chia sẻ link được), form, client state dùng chung (ít hơn mọi người nghĩ — Zustand hoặc Context nếu hiếm đổi), và server state (TanStack Query hoặc Server Component). Server state là cache nên phải có dedupe, staleTime, invalidate sau mutation, và huỷ request cũ — tự viết bằnguseEffectthì gần như chắc chắn dính race condition: lab của tôi cho thấy response chậm của từ khoá cũ ghi đè kết quả mới. Context không có selector, đổi value là mọi consumer render lại kể cả khi bọcmemo— lab: 200 lần render thừa so với 0 khi dùng store có selector. Store ngoài React phải đọc quauseSyncExternalStore, không thì bị tearing khi render concurrent."
1. Bài toán gốc và bối cảnh ra đời
flowchart LR A["2013 React
state trong component
→ prop drilling"] --> B["2014 Flux (Facebook)
luồng một chiều
dispatcher + stores"] B --> C["2015 Redux
1 store, reducer thuần
time-travel debug"] C --> D["2018 Context API mới
truyền giá trị qua cây
không phải state manager"] C --> E["2019+ React Query, SWR
tách server state
thành cache riêng"] D --> F["2019+ Zustand, Jotai
store nhỏ + selector"] E --> G["2023+ Server Components
lấy dữ liệu ngay trên server"]
Redux giải quyết prop drilling và làm luồng dữ liệu đoán được. Nhưng các team dần nhận ra 70–80% state trong Redux
store là dữ liệu từ API kèm cờ isLoading, error, lastFetched — tự viết lại
một cái cache tồi, cho từng endpoint. React Query (Tanner Linsley, 2019) và SWR (Vercel) ra đời từ nhận xét đó:
server state là một loại khác, cần công cụ khác. Phần còn lại (client state thật) nhỏ đến mức store tối giản như
Zustand là đủ.
| Công cụ | Tối ưu cho | Không tối ưu cho |
|---|---|---|
| Context | Truyền giá trị ít đổi qua cây (theme, locale, user đã đăng nhập, dependency injection) | Giá trị đổi thường xuyên, nhiều consumer |
| Redux Toolkit | App lớn, nhiều người, cần luồng sự kiện rõ ràng, devtools, middleware | App nhỏ; server state (dù có RTK Query) |
| Zustand | Client state dùng chung, API nhỏ, selector sẵn | Quy ước chặt cho team lớn (tự do quá) |
| TanStack Query | Server state: cache, dedupe, refetch, mutation, optimistic | Client state thuần; dữ liệu realtime đẩy liên tục (cần kết hợp) |
2. Mô hình tư duy cốt lõi
Ý tưởng 1 — Mỗi loại state có một "chủ sở hữu" và một chỗ ở đúng:
| Loại | Ví dụ | Chủ sở hữu | Công cụ |
|---|---|---|---|
| UI cục bộ | Dropdown đang mở | Một component | useState |
| URL | Trang 3, lọc "giày" | Thanh địa chỉ | Router / searchParams |
| Form | Giá trị đang gõ, lỗi validate | Form | React Hook Form / useActionState |
| Client state dùng chung | Giỏ hàng chưa lưu, theme | Client | Zustand / Context (nếu hiếm đổi) |
| Server state | Danh sách sản phẩm, đơn hàng | Server | TanStack Query / Server Component |
Ý tưởng 2 — Server state là cache. Mọi câu hỏi về nó là câu hỏi về cache: key là gì? bao lâu thì cũ
(staleTime)? bao lâu thì xoá (gcTime)? ghi xong thì invalidate gì? hai request cùng key thì sao?
response về sai thứ tự thì sao? Dev backend đã quen các câu hỏi này với Redis — ở frontend nó y hệt.
3. Internals và quyết định thiết kế
3.1 Context: đổi value → mọi consumer render, memo không chặn được
Provider so value cũ và mới bằng Object.is. Khác → React đi xuống cây, đánh dấu mọi component gọi
useContext của context đó, bỏ qua cả memo ở giữa. Không có khái niệm "chỉ phần cart".
Đánh đổi: cơ chế cực đơn giản, không cần thư viện; cái giá là không có selector. Tách nhiều context nhỏ giúp được một phần nhưng khó quản lý.
3.2 Zustand: store ngoài React + selector + useSyncExternalStore
Store là một object JS thường nằm ngoài React. Hook useStore(selector) đăng ký lắng nghe qua
useSyncExternalStore; mỗi khi store đổi, nó chạy selector và so kết quả với lần trước bằng Object.is.
Giống → không render. Lab: 20 component chỉ đọc cart, đổi notif 10 lần:
10 lần đổi "notif" (20 component chỉ đọc "cart", đều bọc memo):
Context 1 object → CartBadge render 200 lần
Zustand selector → CartBadge render 0 lần
cart. Store có selector so kết quả của selector, nên chỉ những
component đọc đúng phần bị đổi mới render.Bẫy của selector: selector trả object mới mỗi lần gọi → Object.is luôn khác → vòng lặp:
Selector trả object mới mỗi lần `s => ({ cart: s.cart })`:
The result of getSnapshot should be cached to avoid an infinite loop
onUncaughtError: Maximum update depth exceeded. ...
Chữa: chọn giá trị nguyên thủy (s => s.cart), gọi nhiều hook nhỏ, hoặc bọc useShallow của Zustand
(so nông từng key).
3.3 useSyncExternalStore và tearing
Trong concurrent rendering, React có thể render dở, nhả luồng, và trong lúc đó code khác đổi store. Component render
trước thấy bản cũ, component render sau thấy bản mới → màn hình "xé" làm hai. useSyncExternalStore gọi
getSnapshot lại trước khi commit; nếu khác lúc render, React vứt kết quả và render lại đồng bộ (không
chia lát) để mọi component thấy cùng một bản.
đọc thẳng store.value → 60 ô trên màn hình: {"v1":9,"v2":51} ← TEARING
useSyncExternalStore → 60 ô trên màn hình: {"v2":60} (nhất quán)
Đánh đổi: nhất quán, nhưng mất lợi ích của transition cho update đến từ store đó (phải render đồng bộ). Đây là lý
do các store ngoài React không "concurrent-friendly" hoàn toàn. react-redux (từ v8) và Zustand (từ v4) đều dùng
useSyncExternalStore bên trong.
3.4 TanStack Query: cache theo key, observer theo component
flowchart LR
subgraph QC["QueryClient (1 cho cả app)"]
QCache["QueryCache
Map: hash(queryKey) → Query"]
end
C1["Component A
useQuery(['post',1])"] --> O1["QueryObserver A"]
C2["Component B
useQuery(['post',1])"] --> O2["QueryObserver B"]
O1 --> Q["Query ['post',1]
state: data, status, dataUpdatedAt
promise đang bay (nếu có)"]
O2 --> Q
Q --> QCache
Q -- "fetch 1 lần, cả 2 cùng chờ" --> API["API"]
- Query key được hash ổn định (object key sắp xếp lại) →
['products', {cat:'ao'}]là một ô cache. - Dedupe: nếu Query đang có promise đang bay, observer mới dùng lại promise đó. Lab: 3 component cùng key → 1 request.
- Vòng đời: fresh (trong
staleTime, không refetch) → stale (hiện data cũ, refetch khi mount/focus/reconnect) → inactive (không còn observer) → saugcTimethì xoá khỏi bộ nhớ. - Mặc định (đọc từ source 5.104):
staleTime: 0;gcTime=3e5ms (5 phút) ở client,Infinityở server;retry= 3 ở client, 0 ở server.
staleTime=0 (mặc định): 3 component cùng key mount → 1 request; unmount rồi mount lại → tổng 2 request
staleTime=60s : 3 component cùng key mount → 1 request; unmount rồi mount lại → tổng 1 request
Đánh đổi của mặc định: staleTime: 0 chọn "luôn đúng" hơn "ít request": mỗi lần quay lại trang, focus
lại tab đều refetch. Hiện data cũ ngay (không spinner) nhưng backend nhận nhiều request hơn bạn nghĩ.
4. Đảm bảo và giới hạn
| Công cụ | Đảm bảo | Không đảm bảo |
|---|---|---|
| Context | Mọi consumer thấy cùng một value trong một lần render | Chỉ render consumer bị ảnh hưởng |
| Zustand (qua uSES) | Không tearing; chỉ render khi selector đổi | Update từ store được time-slice như transition |
| TanStack Query | Dedupe request đang bay cùng key; observer cùng key thấy cùng data | Dedupe request đã xong (đó là việc của staleTime); huỷ request nếu queryFn không dùng signal; thứ tự giữa các mutation |
useEffect + fetch tự viết | Không có gì | Thứ tự response, dedupe, cache, retry, huỷ |
Khung lý thuyết: cache ở client là một replica đọc được của server, cập nhật bất đồng bộ → mặc định là eventual
consistency (nhất quán sau cùng). Muốn "read-your-own-writes" (thấy ngay cái mình vừa ghi) thì phải invalidate hoặc
setQueryData sau mutation — thư viện không tự biết mutation nào ảnh hưởng query nào.
5. Failure modes
5.1 Race condition: response cũ ghi đè response mới
Lab: API giả, từ khoá "a" trả sau 300ms, "ab" trả sau 50ms. Người dùng gõ "a" rồi "ab".
naive q cuối = "ab" → màn hình hiện: kết quả cho "a" | request gửi 2, chạy xong 2, huỷ 0
ignore q cuối = "ab" → màn hình hiện: kết quả cho "ab" | request gửi 2, chạy xong 2, huỷ 0
abort q cuối = "ab" → màn hình hiện: kết quả cho "ab" | request gửi 2, chạy xong 1, huỷ 1
Hệ quả: sai dữ liệu, không lỗi. Tệ nhất ở màn tìm kiếm, chuyển tab chi tiết đơn hàng (hiện đơn A khi đang ở URL đơn B), bộ lọc báo cáo. Bẫy nổ trễ: ở máy dev API nhanh đều nên không thấy, production mạng 3G mới lộ.
Chữa: AbortController tốt hơn cờ ignore vì huỷ luôn request (tiết kiệm băng thông, giảm
tải server). TanStack Query truyền sẵn signal vào queryFn và chỉ gán data theo key hiện tại nên không
dính lỗi này — nhưng request chỉ thực sự bị huỷ nếu bạn chuyển signal vào fetch.
5.2 Query key thiếu tham số → hiện dữ liệu của bộ lọc khác
queryKey ['products'] → chọn "giay", màn hình: giay: sản phẩm loại ao
queryKey ['products', { cat }] → chọn "giay", màn hình: giay: sản phẩm loại giay
Key là định danh của cache. Thiếu tham số nào trong key thì mọi giá trị của tham số đó dùng chung một ô cache.
Bẫy nổ trễ kinh điển: ai đó thêm tham số cat vào queryFn mà quên thêm vào key. Quy tắc:
mọi biến mà queryFn dùng phải nằm trong key — có ESLint plugin của TanStack bắt lỗi này
(exhaustive-deps của query; chưa verify trong lab).
5.3 Optimistic update và rollback
Optimistic like, server trả lỗi:
42ms UI likes=11 mutation=pending ← UI tăng ngay
104ms UI likes=10 mutation=pending ← server lỗi, onError trả về giá trị cũ
150ms UI likes=10 mutation=error
Người dùng thấy số "nháy" lên rồi xuống. Chấp nhận được với like; không chấp nhận được với số dư, tồn kho, thanh
toán — những thứ đó không nên optimistic. Bẫy: quên cancelQueries trong onMutate → một refetch
đang bay về sau sẽ ghi đè giá trị optimistic.
5.4 Tearing với store tự viết
Lab mục 3.3: 9 ô hiện v1, 51 ô hiện v2 trong cùng một màn hình. Chỉ xảy ra khi có concurrent rendering (transition, Suspense) và store tự viết đọc thẳng. Khó tái hiện bằng tay, rất khó debug trên production.
5.5 Tự DDoS backend
staleTime: 0+refetchOnWindowFocus+ 10 query trên một trang = mỗi lần người dùng alt-tab là 10 request.retry: 3mặc định retry cả lỗi 4xx (403,422) — 4 request cho một lỗi chắc chắn không tự hết.invalidateQueries()không kèm key → refetch mọi query đang hiển thị.- Effect sai deps gọi API lặp (sự cố Cloudflare 9/2025 — xem bài Hooks).
6. Vận hành trong production
Cấu hình mặc định nên đặt lại
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000, // đa số dữ liệu chấp nhận cũ 30s; dữ liệu nhạy thì đặt riêng
retry: (count, err) => err.status >= 500 && count < 2, // không retry 4xx
refetchOnWindowFocus: true, // giữ, nhưng staleTime chặn bớt
},
},
});
Con số 30s là ví dụ minh hoạ — chọn theo độ "nhạy" của dữ liệu từng màn.
Metric
| Metric | Nói lên điều gì |
|---|---|
| Request / phiên / endpoint (APM backend, nhóm theo header phiên bản app) | Refetch quá nhiều, vòng lặp effect, retry bão |
| Tỉ lệ request bị huỷ (client log) | Người dùng gõ nhanh / chuyển trang nhanh; cân nhắc debounce |
| Lỗi 4xx lặp lại cùng phiên | Retry sai chính sách |
| Bộ nhớ tab sau 1 giờ dùng | gcTime quá dài với dữ liệu lớn, store tích luỹ không dọn |
Gánh nặng cho team
Cần một quy ước query key factory (một file định nghĩa mọi key) và quy ước mutation nào invalidate key nào.
Không có quy ước, sau 1 năm sẽ có bug "sửa xong không thấy đổi" ở khắp nơi. Debug 3h sáng: mở TanStack Query Devtools xem
key, trạng thái fresh/stale, thời điểm dataUpdatedAt; tab Network xem request có bị huỷ không.
7. So sánh và ma trận quyết định
| Tiêu chí | Context | Redux Toolkit | Zustand | Jotai | TanStack Query | Server Component |
|---|---|---|---|---|---|---|
| Loại state | Ít đổi, DI | Client (RTK Query cho server) | Client | Client, dạng atom | Server | Server, đọc lúc render |
| Selector / render chính xác | Không | Có | Có | Có (theo atom) | Có (select) | Không cần (không re-render ở client) |
| Boilerplate | Rất ít | Vừa | Rất ít | Ít | Ít | Ít |
| Devtools | React DevTools | Rất tốt (time-travel) | Qua middleware | Có | Rất tốt | Hạn chế |
| Bundle | 0 | Lớn nhất | Rất nhỏ | Nhỏ | Vừa | 0 ở client |
| Tuyển người | Ai cũng biết | Rất phổ biến | Phổ biến | Ít | Phổ biến | Đang tăng |
| Đã lab | Có | Chưa | Có | Chưa | Có | Có (bài SSR/RSC) |
Mặc định của tôi cho dự án mới: server state → TanStack Query (SPA) hoặc Server Component (Next.js); client state dùng chung → Zustand; theme/locale/user → Context; bộ lọc/phân trang → URL.
Dùng Redux Toolkit khi: team lớn đã quen Redux, luồng sự kiện phức tạp cần middleware và time-travel debug.
Tránh: đưa server state vào Redux/Zustand; dùng Context cho giá trị đổi nhiều lần mỗi giây; fetch tay tronguseEffectở code production.
8. Góc nhìn tech lead: tác động tổ chức
- ADR "phân loại state": ghi bảng 5 loại ở mục 2 vào ADR. Tranh luận "dùng Redux hay Zustand" sẽ ngắn đi rất nhiều khi mọi người đồng ý rằng 80% state là server state.
- Tác động tới backend (phần bạn mạnh): frontend có cache thì API nên được thiết kế cho cache: resource ổn định,
có
updated_at/ETag, mutation trả về bản ghi mới để clientsetQueryDatakhỏi refetch, mutation idempotent (retry an toàn — xem bài idempotency key ở nhóm Laravel), rate limit theo user. - Exit strategy: không gọi
useQueryrải rác trong component. Bọc trong custom hook theo nghiệp vụ (useProduct(id),useUpdateCart()). Đổi TanStack sang SWR/RTK Query/Server Component chỉ sửa lớp hook đó. - Vendor lock-in: thấp — Zustand/TanStack đều MIT, nhỏ, có thể fork. Rủi ro lớn hơn là "lock-in vào cách nghĩ": codebase trộn 3 kiểu state management vì mỗi giai đoạn một người quyết.
9. Anti-patterns và bài học thực tế
- Copy server data vào
useState/Redux rồi tự đồng bộ → hai nguồn sự thật, luôn lệch. - Một Context khổng lồ chứa user + cart + theme + notif → mọi thứ render lại mỗi khi có thông báo (lab: 200 render).
- Fetch trong
useEffectkhông cleanup → race condition (lab 5.1). - Query key thiếu tham số → hiện dữ liệu của bộ lọc khác (lab 5.2).
- Selector trả object mới → vòng lặp vô hạn (lab 3.2).
- Optimistic update cho tiền/tồn kho → người dùng thấy "thanh toán thành công" rồi bị hoàn tác.
- Lưu state của URL trong store (trang, bộ lọc) → F5 mất, không chia sẻ link được, nút Back không chạy.
Postmortem công khai liên quan: sự cố Cloudflare 12/9/2025 — gọi API lặp do useEffect sai deps (chi
tiết ở bài Hooks). Bài học rút cho data fetching: tập trung việc lấy dữ liệu vào một thư viện
có dedupe/cache thay vì effect tự viết, và có rate limit phía backend cho chính frontend của mình.
Điều người làm lâu đều biết: câu "chúng ta cần global state" thường có nghĩa là "chúng ta cần cache dữ liệu server". Hỏi lại "dữ liệu này ai sở hữu?" trước khi chọn thư viện.
10. Câu hỏi phụ interviewer hay hỏi tiếp
H: Context có phải state management không?
Đ: Không, nó là cơ chế truyền giá trị (dependency injection). State vẫn nằm ở useState của Provider. Nó không có
selector nên không hợp cho giá trị đổi nhiều.
H: staleTime khác gcTime?
Đ: staleTime: bao lâu thì data bị coi là cũ (cũ thì refetch khi có dịp). gcTime: data không còn ai
dùng thì giữ trong bộ nhớ bao lâu rồi xoá. Mặc định 0 và 5 phút.
H: Làm sao tránh race condition khi fetch?
Đ: Cleanup trong effect (AbortController hoặc cờ ignore), hoặc dùng thư viện gắn data theo key. Server Component
thì không có vấn đề này ở client.
H: Tearing là gì, khi nào xảy ra?
Đ: Các phần UI hiển thị các phiên bản khác nhau của cùng dữ liệu, do store ngoài đổi giữa lúc React render dở (concurrent).
Chữa bằng useSyncExternalStore. Lab: 9 ô v1 / 51 ô v2.
H: Khi nào vẫn dùng Redux?
Đ: Team lớn, logic client phức tạp (editor, workflow nhiều bước), cần middleware, time-travel debug, quy ước chặt. Không phải
để chứa dữ liệu API.
H: Invalidate sau mutation thế nào cho đúng?
Đ: Invalidate đúng key bị ảnh hưởng (['products', id] và danh sách ['products'] theo prefix), hoặc
setQueryData bằng bản ghi server trả về để khỏi refetch. Không gọi invalidateQueries() trống.
H: Optimistic update có rủi ro gì?
Đ: Nháy UI khi rollback; refetch đang bay ghi đè (phải cancelQueries); nhiều mutation liên tiếp rollback sai
thứ tự. Chỉ dùng cho thao tác rủi ro thấp.
Phụ lục: lab đã chạy
Môi trường: như bài Cơ chế render, thêm @tanstack/react-query@5
(5.104.0) và zustand@5 (5.0.15).
docker exec react-lab npm i @tanstack/react-query@5 zustand@5
docker exec react-lab npx tsx 06-race.tsx
docker exec react-lab npx tsx 07-context-vs-store.tsx
docker exec react-lab npx tsx 08-tanstack.tsx # file cuối có process.exit(0): timer gcTime giữ process sống
docker exec react-lab npx tsx 08b-key.tsx
docker exec react-lab npx tsx 09-tearing.tsx
06-race.tsx (phần chính):
function api(q, signal) { // "a" chậm 300ms, "ab" nhanh 50ms
return new Promise((res, rej) => {
const t = setTimeout(() => res(`kết quả cho "${q}"`), q === 'a' ? 300 : 50);
signal?.addEventListener('abort', () => { clearTimeout(t); rej(new DOMException('aborted', 'AbortError')); });
});
}
useEffect(() => {
if (mode === 'naive') { api(q).then(setRes); return; }
if (mode === 'ignore') { let ignore = false; api(q).then(r => { if (!ignore) setRes(r); }); return () => { ignore = true; }; }
const ac = new AbortController();
api(q, ac.signal).then(setRes).catch(e => { if (e.name !== 'AbortError') throw e; });
return () => ac.abort();
}, [q, mode]);
09-tearing.tsx (phần chính):
const store = { value: 'v1', subs: new Set() };
const burn = () => { const t = performance.now(); while (performance.now() - t < 2); };
const Direct = () => { burn(); return <i>{store.value}</i>; };
const Synced = () => { burn(); return <i>{useSyncExternalStore(subscribe, () => store.value)}</i>; };
startTransition(() => setShow(true)); // render 60 ô, mỗi ô 2ms, React nhả luồng mỗi ~5ms
await sleep(40); setStore('v2'); // đổi store GIỮA lúc React đang render dở
await sleep(1500); // jsdom chậm: 400ms chưa đủ để render xong 60 ô