Làm chủ TanStack Query v5 trong React: Quản lý Server State, Caching và Optimistic Updates thực chiến

Development tutorial - IT technology blog
Development tutorial - IT technology blog

Nỗi đau mang tên useEffect khi gọi API

Trước khi chuyển sang dùng TanStack Query (tiền thân là React Query), quy trình fetch data trong team mình xoay quanh bộ ba quen thuộc: useState cho data, isLoading, error, rồi bọc API call trong useEffect. Lúc dự án chỉ có vài màn hình CRUD cơ bản, cách này chạy khá ổn. Nhưng khi hệ thống chạm mốc hơn 40 màn hình, rắc rối lập tức ập tới:

  • Bắn trùng request: Hai widget ở Header và Sidebar cùng cần danh sách thông báo. Kết quả là trình duyệt bắn cùng lúc 2 request y hệt nhau lên backend chỉ trong 100ms.
  • Dữ liệu lệch pha (Stale data): User vừa đổi tên hiển thị ở trang Profile, bấm quay lại trang Dashboard thì tên cũ vẫn nằm trơ trơ. Lý do đơn giản: component Dashboard chưa unmount nên state không tự kích hoạt lấy data mới.
  • Code boilerplate phình to: Viết đi viết lại AbortController, cờ isMounted, logic retry và khối try/catch khiến một component 50 dòng UI phải gánh thêm 100 dòng code xử lý network.

Nhiều team giải quyết bằng cách nhét toàn bộ data từ API vào Redux Toolkit hoặc Zustand. Sau nửa năm đưa TanStack Query v5 vào production, mình nhận ra làm vậy chẳng khác nào tự làm khó mình. State giao diện và dữ liệu từ máy chủ bản chất rất khác nhau, gom chung lại chỉ khiến codebase thêm rối rắm.

3 khái niệm cốt lõi cần thông suốt

Thay vì học vẹt hàng tá option trong tài liệu, bạn chỉ cần nắm vững 3 tư duy nền tảng dưới đây.

1. Ranh giới giữa Client State và Server State

Xác định sai loại state sẽ khiến cấu trúc app đi sai hướng ngay từ đầu:

  • Client State: Dữ liệu thuần UI, luôn đồng bộ và nằm trọn vẹn trong RAM của trình duyệt. Ví dụ: modal mở hay đóng, tab nào đang active, toggle dark mode. Với nhóm này, useState hay một store nhỏ gọn với Zustand là đủ.
  • Server State: Dữ liệu thực sự thuộc về database phía backend. Nó bất đồng bộ, có thể bị user khác sửa đổi bất kỳ lúc nào mà trình duyệt không hề hay biết. Loại dữ liệu này đòi hỏi cơ chế cache, revalidate và retry liên tục. Đây chính là sân chơi của TanStack Query.

2. staleTime khác gì gcTime?

Đây là điểm gây hiểu lầm nhiều nhất, dẫn đến tình trạng app spam API liên tục hoặc rò rỉ RAM:

  • staleTime (mặc định: 0): Thời gian dữ liệu được xem là “tươi mới”. Chừng nào data còn fresh, TanStack Query sẽ đọc thẳng từ RAM mà không thèm gọi server. Với staleTime: 0 mặc định, dữ liệu vừa fetch xong đã bị coi là “cũ”. Cứ mỗi lần user chuyển tab rồi quay lại, app sẽ âm thầm gọi API để sync data mới.
  • gcTime (Garbage Collection Time – v5 đổi tên từ cacheTime, mặc định: 5 phút): Thời gian dữ liệu không dùng tới (inactive) được giữ trong bộ nhớ. Khi không còn component nào trên màn hình subscribe query key đó, đồng hồ gcTime bắt đầu đếm ngược. Hết giờ, garbage collector sẽ dọn dẹp sạch sẽ để giải phóng RAM.

3. Chuẩn hóa Query Key theo phân tầng

Từ bản v5, Query Key bắt buộc phải là một mảng (Array). Thư viện dùng cơ chế hash mảng theo chiều sâu để định danh cache. Hãy gom key thành factory object theo thứ tự từ bao quát đến chi tiết:

// Query Key Factory: sạch sẽ và dễ invalidate hàng loạt
export const todoKeys = {
  all: ['todos'] as const,
  lists: () => [...todoKeys.all, 'list'] as const,
  list: (filters: { status?: string }) => [...todoKeys.lists(), filters] as const,
  details: () => [...todoKeys.all, 'detail'] as const,
  detail: (id: string) => [...todoKeys.details(), id] as const,
};

Thực hành từng bước với TanStack Query v5

Chúng ta sẽ đi từ khâu setup nền móng, query dữ liệu với cú pháp v5 cho tới kỹ thuật Optimistic Updates.

Bước 1: Cài đặt và cấu hình QueryClient chuẩn

Cài đặt core package cùng công cụ DevTools:

npm install @tanstack/react-query @tanstack/react-query-devtools

Tại file root (như App.tsx hoặc main.tsx), khởi tạo QueryClient. Đừng để staleTime mặc định bằng 0 nếu không muốn backend quá tải:

import React from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import TodoApp from './TodoApp';

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60 * 2, // Data giữ độ "tươi" trong 2 phút
      gcTime: 1000 * 60 * 10,    // Giữ cache rảnh rỗi trong RAM tối đa 10 phút
      retry: 1,                 // API sập thì thử lại 1 lần rồi mới báo lỗi
      refetchOnWindowFocus: false, // Tắt tự động fetch lại khi user focus tab
    },
  },
});

export default function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <TodoApp />
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
}

Bước 2: Lấy dữ liệu với useQuery

TanStack Query v5 đã bỏ hoàn toàn kiểu truyền tham số rời rạc. Giờ đây, hàm chỉ nhận duy nhất một object cấu hình. Đồng thời, cờ isPending thay thế cho isLoading khi query chưa có bất kỳ dữ liệu nào:

import { useQuery } from '@tanstack/react-query';
import { todoKeys } from './queryKeys';

interface Todo {
  id: string;
  title: string;
  completed: boolean;
}

const fetchTodos = async (): Promise<Todo[]> => {
  const res = await fetch('/api/todos');
  if (!res.ok) throw new Error('Không thể tải danh sách công việc');
  return res.json();
};

export function TodoList() {
  const { data: todos, isPending, isError, error } = useQuery({
    queryKey: todoKeys.lists(),
    queryFn: fetchTodos,
  });

  if (isPending) return <p>Đang đồng bộ dữ liệu...</p>;
  if (isError) return <p className="error">Lỗi: {error.message}</p>;

  return (
    <ul>
      {todos.map((todo) => (
        <li key={todo.id}>
          <span style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
            {todo.title}
          </span>
        </li>
      ))}
    </ul>
  );
}

Bước 3: Thay đổi dữ liệu và auto refetch với useMutation

Khi cần POST, PUT hoặc DELETE, ta dùng useMutation. Chạy xong mutation, chỉ cần gọi invalidateQueries là TanStack Query tự động kéo dữ liệu mới nhất về các component đang hiển thị.

Trong một dự án e-commerce gần đây của team mình, việc áp dụng cơ chế này đã cắt giảm hoàn toàn mớ logic truyền callback từ component cha xuống component con, giảm bớt hàng tá action Redux rườm rà:

import React, { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { todoKeys } from './queryKeys';

const createTodo = async (title: string) => {
  const res = await fetch('/api/todos', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ title, completed: false }),
  });
  if (!res.ok) throw new Error('Tạo todo thất bại');
  return res.json();
};

export function AddTodo() {
  const [title, setTitle] = useState('');
  const queryClient = useQueryClient();

  const mutation = useMutation({
    mutationFn: createTodo,
    onSuccess: () => {
      // Đánh dấu cache list todo đã cũ -> trigger background fetch lại ngay
      queryClient.invalidateQueries({ queryKey: todoKeys.lists() });
      setTitle('');
    },
  });

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (title.trim()) mutation.mutate(title.trim());
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        value={title}
        onChange={(e) => setTitle(e.target.value)}
        placeholder="Thêm việc cần làm..."
      />
      <button type="submit" disabled={mutation.isPending}>
        {mutation.isPending ? 'Đang lưu...' : 'Thêm'}
      </button>
    </form>
  );
}

Bước 4: UI mượt mà không độ trễ với Optimistic Updates

Khi mạng chập chờn với ping 400ms, user bấm tick hoàn thành công việc rồi phải chờ nửa giây sau checkbox mới sáng lên. Trải nghiệm đó rất khó chịu.

Kỹ thuật Optimistic Update giải quyết triệt để vấn đề này. Giao diện đổi trạng thái ngay khi vừa click. Server xử lý ngầm phía sau, nếu xui xẻo gặp lỗi 500 thì giao diện tự rollback về nguyên trạng:

import { useMutation, useQueryClient } from '@tanstack/react-query';
import { todoKeys } from './queryKeys';

interface TogglePayload {
  id: string;
  completed: boolean;
}

const patchTodo = async ({ id, completed }: TogglePayload) => {
  const res = await fetch(`/api/todos/${id}`, {
    method: 'PATCH',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ completed }),
  });
  if (!res.ok) throw new Error('Không thể cập nhật trạng thái');
  return res.json();
};

export function useToggleTodo() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: patchTodo,
    onMutate: async (updatedTodo) => {
      // Dừng các request query list đang bay để tránh race condition
      await queryClient.cancelQueries({ queryKey: todoKeys.lists() });

      // Backup lại cache cũ để sẵn sàng rollback nếu có lỗi
      const previousTodos = queryClient.getQueryData<Todo[]>(todoKeys.lists());

      // Ép cache hiển thị ngay data mới
      queryClient.setQueryData<Todo[]>(todoKeys.lists(), (old = []) =>
        old.map((todo) =>
          todo.id === updatedTodo.id ? { ...todo, completed: updatedTodo.completed } : todo
        )
      );

      // Trả snapshot vào context
      return { previousTodos };
    },
    onError: (_err, _variables, context) => {
      // Rollback về bản backup an toàn khi API tèo
      if (context?.previousTodos) {
        queryClient.setQueryData(todoKeys.lists(), context.previousTodos);
      }
    },
    onSettled: () => {
      // Luôn sync lại với dữ liệu chuẩn của backend dù thành công hay lỗi
      queryClient.invalidateQueries({ queryKey: todoKeys.lists() });
    },
  });
}

Tổng kết kinh nghiệm thực chiến

Sau khi refactor toàn bộ hệ thống sang TanStack Query v5, team mình giảm được khoảng 60% code quản lý state. Tình trạng lệch data giữa các màn hình biến mất hoàn toàn. Dưới đây là 3 lời khuyên thực tế bạn nên lưu tâm:

  • Đừng để staleTime mặc định bằng 0: Với dữ liệu ít thay đổi như danh mục hàng hóa hay thông tin profile, hãy set staleTime từ 1 đến 5 phút. Backend team sẽ rất biết ơn bạn vì không phải gánh hàng nghìn request rác mỗi ngày.
  • Bật DevTools trong môi trường dev: Công cụ trực quan này giúp bạn soi rõ key nào đang Fresh, Stale hay Inactive và đếm được chính xác component nào đang render lại.
  • Luôn đóng gói thành Custom Hook: Đừng nhét useQuery trực tiếp vào trong component view. Hãy bọc nó thành hook riêng như useTodos() hay useCreateTodo() để tái sử dụng logic gọn gàng hơn.

Nếu bạn đang ngập trong mớ useEffect hỗn loạn hoặc mệt mỏi vì dispatch Redux chỉ để hiển thị cái spinner, hãy thử TanStack Query v5 ngay hôm nay. Bạn sẽ thấy việc quản lý server state nhẹ nhàng hơn rất nhiều.

Share: