jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

TanStack Query · Phần 14 — Realtime: WebSocket, SSE & cache

Ghép dữ liệu realtime với React Query: đẩy sự kiện vào cache bằng setQueryData thay vì invalidate, quyết định patch vs invalidate, gom sự kiện, một query làm kênh subscription, và cập nhật từng phần an toàn type.

React Query là thư viện request/response — bạn hỏi, server trả. Nhưng nhiều app cần chiều ngược lại: server đẩy cập nhật xuống (chat, thông báo, giá realtime, presence). Tin tốt: React Query không cần thay thế cho realtime — nó là cache hoàn hảo để realtime ghi vào. WebSocket/SSE lo phần vận chuyển, React Query lo phần lưu trữ và phân phối tới component.

Phần này chỉ cách hợp nhất hai thế giới: giữ một query làm “nguồn sự thật” trong cache, rồi để stream realtime cập nhật chính cache đó.


1. Bản đồ kiến trúc: vận chuyển vs cache

Đừng nghĩ realtime là “thay thế” React Query. Hãy nghĩ theo lớp: WebSocket/SSE là vận chuyển (đẩy byte từ server xuống), React Query là cache + phân phối (lưu trữ chuẩn hoá, structural sharing, thông báo đúng observer). Cầu nối giữa hai lớp là một handler nhỏ gọi setQueryData hoặc invalidateQueries.

  Server ──push──► [ WebSocket / SSE ]        ← lớp VẬN CHUYỂN

                          ▼  onmessage(event)
                  zod.safeParse(payload)        ← biên: validate

              setQueryData  /  invalidateQueries  ← CẦU NỐI


                   [ QueryCache ]               ← lớp LƯU TRỮ
              (structural sharing, gc, observers)

                 notify observers (chỉ key đổi)


        useQuery(...) trong component           ← lớp PHÂN PHỐI

Lợi ích của ranh giới này: component không biết data tới từ fetch hay từ socket — chúng chỉ đọc useQuery. Bạn có thể thêm/bỏ realtime mà không sửa một dòng UI nào. Mọi logic đồng bộ sống trong cầu nối.

Tư duy cốt lõi. Realtime không tạo ra một “store” thứ hai. Nó chỉ là một nguồn ghi khác vào đúng cái cache mà useQuery đang đọc. Một nguồn sự thật, nhiều nguồn ghi.


2. Hai chiến lược: invalidate vs setQueryData

Khi nhận một sự kiện realtime, bạn có đúng hai cách phản ứng. Đây là quyết định quan trọng nhất của cả phần này.

Chiến lượcKhi nào dùngCơ chếRequest thêm?Rủi ro
Invalidate on eventSự kiện chỉ là tín hiệu “có gì đó đổi”, không kèm data; hoặc data cần server tính lại (tổng, quyền, join phức tạp)invalidateQueries đánh dấu stale → query active refetchCó — mỗi sự kiện tốn 1 requestTrễ một vòng round-trip; dễ tạo bão request nếu sự kiện dồn dập
Push into cachePayload chứa đủ entity mới (object đầy đủ); hoặc sự kiện đến dồn dập (giá tick)setQueryData ghi thẳng payload vào cacheKhông — 0 requestBạn tự chịu trách nhiệm shape; payload thiếu field → cache sai

Quy tắc chọn (decision tree):

Sự kiện đến

   ├─ Payload có đủ entity mới? ──No──► invalidateQueries (để server dựng lại)
   │            │Yes
   │            ▼
   ├─ Cần tính lại ở server (tổng/quyền/join)? ──Yes──► invalidateQueries
   │            │No
   │            ▼
   ├─ Tần suất cao (>vài lần/giây)? ──Yes──► setQueryData (+ batch, mục 8)
   │            │No
   │            ▼
   └────────────────────────────────────────► setQueryData (patch trực tiếp)

Vì sao không luôn invalidate cho “an toàn”? Vì invalidate biến mỗi sự kiện thành một request. Một phòng chat 50 người gõ phím → 50 invalidate/giây → 50 refetch/giây. Patch (setQueryData) ghi thẳng, 0 request, và giữ UI mượt. Invalidate chỉ “an toàn” khi tần suất thấp.


3. WebSocket vs SSE (EventSource) — chọn vận chuyển

Hai vận chuyển phổ biến nhất. SSE bị đánh giá thấp: nếu bạn chỉ cần server → client thì nó đơn giản hơn nhiều và tự reconnect miễn phí.

Tiêu chíWebSocketSSE (EventSource)
ChiềuHai chiều (client gửi + nhận)Một chiều (chỉ server → client)
ReconnectTự viết (socket đóng là hết)Tự động, có Last-Event-ID để bắt tiếp
PayloadText hoặc binary (ArrayBuffer/Blob)Chỉ UTF-8 text
Định dạngTự do (thường JSON)Khung event: + data: chuẩn
Tên sự kiệnTự route trong onmessageNative: addEventListener('price', …)
AuthHeader bị giới hạn lúc handshake → thường nhét token vào query string hoặc subprotocolCookie tự gửi; header tuỳ trình duyệt; thường dùng cookie/withCredentials
Giao thứcws:// / wss:// (HTTP upgrade)HTTP thường (text/event-stream)
Giới hạn kết nốiCao~6 kết nối/domain trên HTTP/1.1 (HTTP/2 đỡ hơn nhiều)
Hợp vớiChat, game, presence, gửi lệnh lên serverGiá, thông báo, feed, log — luồng đẩy một chiều

Nguyên tắc: cần gửi lên → WebSocket; chỉ nhận xuống → SSE. Đừng kéo WebSocket vào chỉ để nhận thông báo — bạn sẽ tự viết lại reconnect mà SSE đã cho không.


4. Hook bắc cầu WebSocket → cache (typed + zod)

Pattern gọn nhất: một hook mở WebSocket một lần ở gốc app, validate payload bằng zod, rồi route từng loại message vào cache. Không any, không as — kiểu được zod suy ra.

import { useEffect } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { z } from 'zod';
import { todoKeys } from '@/features/todos/keys';
import { todoSchema } from '@/features/todos/schema';

// Discriminated union, parse tại biên → không cần `as`.
const wsEventSchema = z.discriminatedUnion('type', [
  z.object({ type: z.literal('todo.updated'), todo: todoSchema }),
  z.object({ type: z.literal('todo.deleted'), id: z.string() }),
  z.object({ type: z.literal('todos.changed') }), // chỉ tín hiệu
]);
type WsEvent = z.infer<typeof wsEventSchema>;

export function useRealtimeSync(): void {
  const qc = useQueryClient();

  useEffect(() => {
    const ws = new WebSocket(import.meta.env.VITE_WS_URL);

    const onMessage = (e: MessageEvent<string>) => {
      const parsed = wsEventSchema.safeParse(JSON.parse(e.data));
      if (!parsed.success) return; // payload lạ → bỏ qua, KHÔNG làm hỏng cache
      route(parsed.data);
    };

    const route = (event: WsEvent) => {
      switch (event.type) {
        case 'todo.updated':
          // Patch: payload đủ data → ghi thẳng cả list lẫn detail.
          qc.setQueryData<Todo[]>(todoKeys.lists(), (old) =>
            old?.map((t) => (t.id === event.todo.id ? event.todo : t)),
          );
          qc.setQueryData(todoKeys.detail(event.todo.id), event.todo);
          return;
        case 'todo.deleted':
          qc.setQueryData<Todo[]>(todoKeys.lists(), (old) =>
            old?.filter((t) => t.id !== event.id),
          );
          qc.removeQueries({ queryKey: todoKeys.detail(event.id) });
          return;
        case 'todos.changed':
          // Tín hiệu mơ hồ → để server quyết định, refetch.
          void qc.invalidateQueries({ queryKey: todoKeys.lists() });
          return;
      }
    };

    ws.addEventListener('message', onMessage);
    return () => {
      ws.removeEventListener('message', onMessage);
      ws.close(); // CLEANUP bắt buộc — nếu không, socket rò mỗi lần remount
    };
  }, [qc]);
}

Component vẫn dùng useQuery(todoKeys.lists()) như thường — chúng không phân biệt được data đến từ fetch hay từ WebSocket. switch trên discriminated union là exhaustive: thêm một loại event mới mà quên xử lý, TS sẽ báo lỗi tại default (thêm default: { const _x: never = event; } nếu muốn ép buộc).


5. Hook SSE với EventSource

Khi chỉ cần một chiều, SSE ngắn hơn và tự reconnect. Dùng instanceof MessageEvent để truy cập .data an toàn type — không cần as.

import { z } from 'zod';

const priceSchema = z.object({ symbol: z.string(), price: z.number() });

export function useSseSync(): void {
  const qc = useQueryClient();

  useEffect(() => {
    const es = new EventSource('/api/stream', { withCredentials: true });

    const onPrice = (e: Event) => {
      if (!(e instanceof MessageEvent)) return;
      const parsed = priceSchema.safeParse(JSON.parse(e.data));
      if (!parsed.success) return;
      // Giá tick dồn dập → luôn patch, không bao giờ invalidate.
      qc.setQueryData(['price', parsed.data.symbol], parsed.data.price);
    };

    es.addEventListener('price', onPrice);
    es.addEventListener('error', () => {
      // EventSource TỰ reconnect; chỉ log/đo, đừng tự đóng vội.
    });

    return () => {
      es.removeEventListener('price', onPrice);
      es.close();
    };
  }, [qc]);
}

Server gửi khung chuẩn text/event-stream:

event: price
data: {"symbol":"AAPL","price":231.4}

event: price
data: {"symbol":"AAPL","price":231.7}

Mỗi khung kết thúc bằng một dòng trống. Trình duyệt tự gắn Last-Event-ID nếu server gửi id: — sau reconnect server có thể phát lại từ điểm đứt.


6. Cập nhật từng phần vào list cache & detail cache

Một entity thường nằm trong nhiều query: danh sách (todoKeys.lists()) và chi tiết (todoKeys.detail(id)). Một sự kiện todo.updated nên ghi vào cả hai để mọi màn hình đồng bộ.

event: todo.updated { id: 7, title: "Mua sữa ✓" }

        ├─► setQueryData(lists())  → map: thay item id=7

        └─► setQueryData(detail(7)) → ghi đè object chi tiết

Tách thành helper tái dùng để mọi nơi patch nhất quán:

function applyTodoUpsert(qc: QueryClient, todo: Todo): void {
  // List: thay tại chỗ; nếu chưa có thì thêm vào đầu.
  qc.setQueryData<Todo[]>(todoKeys.lists(), (old) => {
    if (!old) return old; // list chưa nạp → bỏ qua, lần fetch sau sẽ có
    const exists = old.some((t) => t.id === todo.id);
    return exists
      ? old.map((t) => (t.id === todo.id ? todo : t))
      : [todo, ...old];
  });
  // Chỉ ghi detail nếu key đó đang tồn tại → tránh "tạo" cache rác.
  if (qc.getQueryData(todoKeys.detail(todo.id)) !== undefined) {
    qc.setQueryData(todoKeys.detail(todo.id), todo);
  }
}

Mẹo “không seed cache rác”. Trả old nguyên vẹn khi old === undefined nghĩa là “list chưa từng nạp” — đừng dựng list từ một sự kiện đơn lẻ (bạn sẽ có list thiếu). Tương tự, chỉ ghi detail khi nó đã tồn tại, để không giữ sống một entity mà chẳng component nào quan tâm.


7. Tin nhắn đến lệch thứ tự (out-of-order)

WebSocket giữ thứ tự trong một kết nối, nhưng sau reconnect, qua nhiều socket, hoặc khi trộn với optimistic update, bạn có thể nhận một bản cũ hơn sau một bản mới hơn. Nếu patch mù, UI sẽ “nhảy lùi”.

Cách chữa: mỗi entity mang một dấu thứ tự (version tăng dần hoặc updatedAt), và chỉ ghi nếu mới hơn:

function applyIfNewer(qc: QueryClient, incoming: Todo): void {
  qc.setQueryData<Todo>(todoKeys.detail(incoming.id), (current) => {
    if (current && current.version >= incoming.version) {
      return current; // đã có bản mới hơn → bỏ qua bản đến trễ
    }
    return incoming;
  });
}
Dấu thứ tựƯuNhược
version (số nguyên tăng)So sánh tuyệt đối, không lệ thuộc đồng hồServer phải cấp và tăng đều
updatedAt (timestamp)Sẵn có trong hầu hết schemaLệch đồng hồ server, độ phân giải ms có thể trùng

Quy tắc: server là nơi cấp dấu thứ tự (đừng tin đồng hồ client). Với danh sách, dùng cùng guard khi map từng item.


8. Gom sự kiện dồn dập (batching/throttle)

Khi sự kiện tới quá nhanh, mỗi setQueryData kích hoạt một lượt structural sharing + thông báo observer. Với hàng trăm tick/giây, gom lại rồi ghi theo nhịp:

function createBatcher<T>(flush: (items: T[]) => void, ms = 100) {
  let buffer: T[] = [];
  let timer: ReturnType<typeof setTimeout> | null = null;
  return (item: T): void => {
    buffer.push(item);
    timer ??= setTimeout(() => {
      const items = buffer;
      buffer = [];
      timer = null;
      flush(items);
    }, ms);
  };
}

// Gom tick 100ms rồi ghi một lần cho cả map giá.
const pushTick = createBatcher<Price>((ticks) => {
  qc.setQueryData<Record<string, number>>(['prices'], (old) => {
    const next = { ...old };
    for (const t of ticks) next[t.symbol] = t.price; // chỉ giữ tick cuối/symbol
    return next;
  });
}, 100);

React 18+ tự batch nhiều setState trong một tick, nhưng gom trước khi chạm cache vẫn cắt số lần structural sharing — với stream tần suất cao, đây là khác biệt giữa 60fps và giật khung.

Cách điều tiếtHành viHợp với
Throttle (mép sau, như trên)Ghi tối đa 1 lần / ms, gộp tick giữa các lầnGiá, đếm, “trạng thái mới nhất thắng”
DebounceChờ im lặng ms rồi mới ghiÔ “đang gõ…” lặng đi thì cập nhật
rAF coalescingGộp theo khung hình (requestAnimationFrame)Cập nhật gắn chặt với vẽ lại UI

9. Reconnect: đồng bộ lại nền tảng + refetchOnReconnect

Realtime có một lỗ hổng cố hữu: khi kết nối rớt rồi nối lại, bạn bỏ lỡ các sự kiện trong lúc gián đoạn → cache trôi khỏi sự thật. Cách chữa: khi open (kể cả lần reconnect), invalidate một lần để lấy snapshot đúng, rồi tiếp tục patch theo stream.

ws.addEventListener('open', () => {
  // Vừa (re)connect: có thể đã lỡ sự kiện → đồng bộ lại nền tảng.
  void qc.invalidateQueries({ queryKey: todoKeys.lists() });
});
 connected  ──events──►  [rớt mạng]  ✗ lỡ 3 events ✗  ──reconnect──► open

                                                     invalidate 1 lần

                                              snapshot đúng ◄─┘  rồi patch tiếp

Kết hợp với refetchOnReconnect: true (mặc định) cho query thường: khi navigator.onLine trở lại, query active tự refetch. Hai cơ chế bổ trợ nhau — refetchOnReconnect lo query React Query quản lý, còn handler open lo riêng “lỗ hổng” của stream realtime.


10. Một socket dùng chung cho nhiều query (dedupe ngoài React)

Nếu mỗi hook mở một socket, 10 component = 10 socket. Giải pháp: một socket singleton sống ngoài React, các query đăng ký nhận message từ nó. Đây cũng là cách QueryClient được tạo ngoài cây React (Phần 9, 12).

// realtime/socket.ts — một instance dùng chung cho cả app.
type Listener = (event: WsEvent) => void;

let socket: WebSocket | null = null;
const listeners = new Set<Listener>();

function ensureSocket(): WebSocket {
  if (socket && socket.readyState <= WebSocket.OPEN) return socket;
  socket = new WebSocket(import.meta.env.VITE_WS_URL);
  socket.addEventListener('message', (e: MessageEvent<string>) => {
    const parsed = wsEventSchema.safeParse(JSON.parse(e.data));
    if (parsed.success) for (const fn of listeners) fn(parsed.data);
  });
  return socket;
}

export function subscribe(fn: Listener): () => void {
  ensureSocket();
  listeners.add(fn);
  return () => {
    listeners.delete(fn);
    if (listeners.size === 0) {
      socket?.close(); // không còn ai nghe → đóng để khỏi rò
      socket = null;
    }
  };
}
// Phía React: hook mỏng chỉ đăng ký vào singleton.
export function useRealtimeSync(): void {
  const qc = useQueryClient();
  useEffect(() => subscribe((event) => routeToCache(qc, event)), [qc]);
}
        ┌───────── một WebSocket singleton ─────────┐
        │  (đếm listener; đóng khi về 0)            │
        └───────────────────┬──────────────────────┘
              ┌─────────────┼─────────────┐
              ▼             ▼             ▼
        useRealtimeSync  Sidebar      Notifications
        (mỗi nơi chỉ add/remove 1 listener)

Lợi: một kết nối, một nguồn parse, reference-count tự đóng. Đây là cách an toàn nhất để nhiều phần UI cùng “nghe” realtime mà không nhân bản socket.


11. Đồng bộ cross-tab với broadcastQueryClient

Người dùng mở 3 tab — bạn không muốn 3 socket 3 cache lệch nhau. broadcastQueryClient (gói @tanstack/query-broadcast-client-experimental) phát thay đổi cache qua BroadcastChannel, để các tab chia sẻ cùng trạng thái:

import { broadcastQueryClient } from '@tanstack/query-broadcast-client-experimental';

broadcastQueryClient({ queryClient, broadcastChannel: 'app-cache' });

Mẫu thực dụng: chỉ một tab “leader” giữ socket thật; các tab khác không mở socket mà nhận cập nhật qua broadcast. Vừa tiết kiệm kết nối, vừa giữ mọi tab đồng bộ. (API còn experimental — ghim version và đọc changelog khi nâng cấp.)


12. An toàn type khi patch từng phần

Payload realtime đến từ JSON — đừng tin mù. Validate biên bằng zod (như Phần 3) trước khi ghi vào cache. Ta đã làm điều này trong mọi ví dụ trên; đây là vì sao nó không thể bỏ:

const parsed = wsEventSchema.safeParse(JSON.parse(e.data));
if (!parsed.success) return; // payload lạ → bỏ qua, cache vẫn nguyên vẹn
qc.setQueryData(todoKeys.detail(parsed.data.todo.id), parsed.data.todo);

Validate ở biên giữ cache luôn đúng shape — nếu không, một payload server lỗi có thể nhét undefined vào field bắt buộc và làm crash mọi component đang đọc query đó. Đây cũng là lý do ta dùng safeParse (trả về kết quả) thay vì parse (ném lỗi): một message rác không nên đánh sập handler và làm rớt cả stream.

Vì sao không as WsEvent? Ép kiểu bằng as chỉ nói dối với compiler — runtime vẫn nhận object sai shape. safeParse kiểm tra thật tại runtime và suy ra type cho nhánh thành công. Realtime là biên không kiểm soát được → bắt buộc parse thật.


13. Gotchas thường gặp

GotchaTriệu chứngCách chữa
Thiếu cleanupSocket rò mỗi lần remount; nhiều kết nối “ma” gửi message trùngLuôn return () => { removeEventListener; ws.close(); } trong useEffect
Stale closureHandler dùng qc/state cũ; patch sai keyĐưa qc vào deps; đọc state qua updater setQueryData((old) => …) thay vì biến ngoài
Bão invalidateSự kiện dồn dập → hàng loạt refetch, server quá tảiĐổi sang setQueryData; nếu phải invalidate thì throttle/gộp key
Seed cache rácList dựng từ một event đơn → thiếu phần tử; detail “sống” vô íchChỉ patch khi old !== undefined; chỉ ghi detail nếu key đã tồn tại
Message lệch thứ tựUI “nhảy lùi” về bản cũGuard bằng version/updatedAt, chỉ ghi nếu mới hơn (mục 7)
Token hết hạn khi reconnectReconnect 401/403, stream chết im lặngLàm mới token trước khi mở lại; nhét token mới vào URL/subprotocol; có backoff
Nhiều socket trùngN component = N kết nối, parse N lầnMột socket singleton + reference-count (mục 10)
Patch ngoài React không re-rendergetQueryData rồi sửa object tại chỗLuôn ghi qua setQueryData với object mới (immutable) để observer được báo

14. Recipe thực chiến

14.1 Live list — chat append tin nhắn mới

const messageSchema = z.object({
  id: z.string(), roomId: z.string(), body: z.string(), version: z.number(),
});

function applyNewMessage(qc: QueryClient, msg: Message): void {
  qc.setQueryData<Message[]>(chatKeys.list(msg.roomId), (old) => {
    if (!old) return old;
    if (old.some((m) => m.id === msg.id)) return old; // chống trùng
    return [...old, msg]; // append cuối; UI tự cuộn xuống
  });
}

Chống trùng theo id quan trọng vì optimistic insert (Phần 6) có thể đã thêm tin nhắn này rồi — khi server echo lại, ta không nhân đôi.

14.2 Presence / “đang gõ…” bằng setQueryData

Trạng thái phù du như “ai đang gõ” cũng là cache — chỉ là không bao giờ fetch ban đầu, chỉ realtime ghi. Dùng một query key riêng với staleTime: Infinity:

function setTyping(qc: QueryClient, roomId: string, userId: string): void {
  qc.setQueryData<string[]>(chatKeys.typing(roomId), (old = []) =>
    old.includes(userId) ? old : [...old, userId],
  );
  // Tự xoá sau 3s nếu không có tín hiệu "vẫn đang gõ" mới.
  window.setTimeout(() => {
    qc.setQueryData<string[]>(chatKeys.typing(roomId), (old = []) =>
      old.filter((u) => u !== userId),
    );
  }, 3000);
}
function TypingIndicator({ roomId }: { roomId: string }) {
  const { data: typing = [] } = useQuery({
    queryKey: chatKeys.typing(roomId),
    queryFn: () => [] as string[], // không có nguồn fetch — realtime ghi
    staleTime: Infinity,
  });
  return typing.length ? <p>{typing.join(', ')} đang gõ…</p> : null;
}

Component đọc presence y hệt đọc data thường — useQuery. Cùng một cache, cùng một API, dù nguồn ghi hoàn toàn là realtime.


15. Bài tập

1. Khi nào nên patch bằng setQueryData và khi nào nên invalidateQueries để phản ứng với một sự kiện realtime?

Lời giải

Patch khi payload sự kiện đủ để dựng giá trị mới (vd object đầy đủ sau khi đổi) hoặc khi sự kiện đến dồn dập (giá tick) — tức thì, không tốn request. Invalidate khi sự kiện chỉ là tín hiệu “có gì đó đổi” không kèm đủ data, hoặc data cần server tính lại (tổng, quyền, join) — đánh đổi một request lấy tính đúng.

2. Vì sao nên invalidateQueries một lần khi WebSocket (re)connect?

Lời giải

Trong lúc mất kết nối, client bỏ lỡ các sự kiện realtime → cache lệch với server. Khi nối lại, invalidate một lần để lấy snapshot đúng làm nền tảng, rồi tiếp tục patch theo stream từ đó. Không làm vậy thì cache sẽ “trôi” dần khỏi sự thật sau mỗi lần gián đoạn.

3. Vì sao phải validate payload realtime trước khi setQueryData, và vì sao dùng safeParse thay vì parse?

Lời giải

Payload đến từ JSON ngoài tầm kiểm soát; một message lỗi/đổi shape có thể ghi data sai vào cache (vd thiếu field bắt buộc), làm crash mọi component đọc query đó. safeParse kiểm tra thật tại runtime và suy ra type cho nhánh thành công (không cần as); nó trả về kết quả thay vì ném lỗi, nên một message rác chỉ bị bỏ qua chứ không đánh sập handler/stream.

4. Khi nào chọn SSE (EventSource) thay vì WebSocket?

Lời giải

Khi luồng dữ liệu một chiều server → client (giá, thông báo, feed, log) và bạn không cần gửi lên qua cùng kênh. SSE đơn giản hơn: tự reconnect (kèm Last-Event-ID để bắt tiếp), khung event:/data: chuẩn, route bằng addEventListener('tên', …) native. WebSocket dành cho khi cần hai chiều (chat, game, gửi lệnh lên). Đổi lại, SSE chỉ text và bị giới hạn ~6 kết nối/domain trên HTTP/1.1.

5. Vì sao một socket singleton ngoài React tốt hơn mở socket trong mỗi hook?

Lời giải

Mở trong mỗi hook → N component tạo N kết nối, parse N lần, và mỗi setQueryData chạy nhiều lần trùng lặp. Một singleton ngoài React giữ một kết nối, một nơi parse, rồi fan-out tới các listener; reference-count tự đóng socket khi không còn ai nghe. Đây cũng là lý do QueryClient được tạo ngoài cây React — vòng đời hạ tầng không nên gắn với vòng đời render của một component.

Nâng cao 1: Dựng một stream giá giả (setInterval phát tick), bắc cầu vào cache bằng batcher 100ms. Mở React Profiler và so sánh số lần render khi patch từng tick so với patch gom nhóm.

Nâng cao 2: Mở app trên 2 tab, bật broadcastQueryClient, và cho một tab giữ socket thật. Kiểm chứng tab còn lại cập nhật mà không mở socket riêng; rồi đóng tab leader và quan sát ai tiếp quản.


Tóm tắt

  • Realtime gồm hai lớp: WebSocket/SSE lo vận chuyển, React Query lo cache + phân phối. Component dùng useQuery không cần biết data đến từ đâu — một nguồn sự thật, nhiều nguồn ghi.
  • Phản ứng sự kiện theo decision tree: patch (setQueryData) khi payload đủ/đến dồn dập; invalidate khi chỉ là tín hiệu mơ hồ hoặc cần server tính lại.
  • Chọn vận chuyển: WebSocket khi cần hai chiều; SSE khi một chiều (đơn giản hơn, tự reconnect).
  • Bắc cầu trong một hook/singleton, validate bằng zod tại biên (safeParse, không as), patch vào cả list lẫn detail, và guard lệch thứ tự bằng version/updatedAt.
  • Gom sự kiện tần suất cao (throttle/debounce/rAF) trước khi chạm cache để giảm structural sharing/re-render.
  • Khi (re)connect, invalidate một lần để đồng bộ lại “lỗ hổng” đã lỡ; refetchOnReconnect lo phần còn lại. broadcastQueryClient đồng bộ cross-tab.
  • Cẩn thận gotchas: thiếu cleanup (rò socket), stale closure, bão invalidate, seed cache rác, token hết hạn khi reconnect.

Phần tiếp theo

Phần 15 — Mutation nâng cao: mutationKey + setMutationDefaults, theo dõi mutation đang chạy bằng useMutationState, chạy tuần tự bằng scope, optimistic update trải trên nhiều query cùng lúc, xử lý lỗi/thành công toàn cục ở MutationCache, và hàng đợi mutation offline.