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-query 5.104.0, zustand 5.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 trong node_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 stateDữ 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 stateDữ liệu chỉ tồn tại ở client (modal đang mở, tab đang chọn, giỏ hàng chưa lưu).
prop drillingTruyề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.
selectorHàm chọn một phần của store: s => s.cart. Component chỉ render lại khi phần đó đổi.
race conditionKế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-revalidateHiện dữ liệu cũ ngay, đồng thời lấy bản mới ở nền rồi cập nhật.
optimistic updateCậ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ộ (useState ngay 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ằng useEffect thì 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ọc memo — lab: 200 lần render thừa so với 0 khi dùng store có selector. Store ngoài React phải đọc qua useSyncExternalStore, 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 choKhông tối ưu cho
ContextTruyề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 ToolkitApp lớn, nhiều người, cần luồng sự kiện rõ ràng, devtools, middlewareApp nhỏ; server state (dù có RTK Query)
ZustandClient state dùng chung, API nhỏ, selector sẵnQuy ước chặt cho team lớn (tự do quá)
TanStack QueryServer state: cache, dedupe, refetch, mutation, optimisticClient 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ạiVí dụChủ sở hữuCông cụ
UI cục bộDropdown đang mởMột componentuseState
URLTrang 3, lọc "giày"Thanh địa chỉRouter / searchParams
FormGiá trị đang gõ, lỗi validateFormReact Hook Form / useActionState
Client state dùng chungGiỏ hàng chưa lưu, themeClientZustand / Context (nếu hiếm đổi)
Server stateDanh sách sản phẩm, đơn hàngServerTanStack 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
Đổi notif: Context làm cả 20 badge render, selector thì không Bên trái: Provider của Context giữ object gồm cart và notif. Mỗi lần notif đổi, cả 20 ô CartBadge nháy đỏ vì phải render lại, dù chúng chỉ đọc cart. Bên phải: store Zustand với selector s => s.cart. Khi notif đổi, selector trả về cùng giá trị, nên 20 ô đứng yên màu xám. Context (1 object) Zustand + selector value = { cart, notif++ } store.notif++ · selector s => s.cart 20 CartBadge (đều bọc memo) · đỏ = render lại · lab đo: 200 lần vs 0 lần cho 10 lần đổi notif
Context không biết component chỉ cầ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"]
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ảoKhông đảm bảo
ContextMọi consumer thấy cùng một value trong một lần renderChỉ render consumer bị ảnh hưởng
Zustand (qua uSES)Không tearing; chỉ render khi selector đổiUpdate từ store được time-slice như transition
TanStack QueryDedupe request đang bay cùng key; observer cùng key thấy cùng dataDedupe 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ếtKhô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
Race condition khi fetch trong useEffect Trục thời gian. Request cho "a" gửi trước, chạy 300ms. Request cho "ab" gửi sau 10ms, chỉ chạy 50ms nên về trước. Màn hình bản naive: lúc đầu trống, khi "ab" về thì hiện kết quả "ab", nhưng khi "a" về muộn thì ghi đè thành kết quả "a" — sai. Bản có cleanup ignore hoặc AbortController: response "a" bị bỏ, màn hình giữ "ab". 0ms300ms fetch("a") ↩ fetch("ab") ↩ về trước naive … kết quả "ab" ✓ kết quả "a" ✗ (đang gõ "ab") ignore / abort kết quả "ab" ✓ (giữ nguyên) response "a" bị bỏ qua / huỷ
Request gửi trước không có nghĩa là về trước. Không có cleanup thì response về cuối cùng thắng, bất kể nó là của từ khoá nào.

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

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

MetricNó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ênRetry sai chính sách
Bộ nhớ tab sau 1 giờ dùnggcTime 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íContextRedux ToolkitZustandJotaiTanStack QueryServer Component
Loại stateÍt đổi, DIClient (RTK Query cho server)ClientClient, dạng atomServerServer, đọc lúc render
Selector / render chính xácKhôngCóCóCó (theo atom)Có (select)Không cần (không re-render ở client)
BoilerplateRất ítVừaRất ítÍtÍtÍt
DevtoolsReact DevToolsRất tốt (time-travel)Qua middlewareCóRất tốtHạn chế
Bundle0Lớn nhấtRất nhỏNhỏVừa0 ở client
Tuyển ngườiAi cũng biếtRất phổ biếnPhổ biếnÍtPhổ biếnĐang tăng
Đã labCóChưaCóChưaCó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 trong useEffect ở code production.

8. Góc nhìn tech lead: tác động tổ chức

9. Anti-patterns và bài học thực tế

  1. Copy server data vào useState/Redux rồi tự đồng bộ → hai nguồn sự thật, luôn lệch.
  2. 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).
  3. Fetch trong useEffect không cleanup → race condition (lab 5.1).
  4. Query key thiếu tham số → hiện dữ liệu của bộ lọc khác (lab 5.2).
  5. Selector trả object mới → vòng lặp vô hạn (lab 3.2).
  6. 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.
  7. 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 ô