Bỏ qua

TanStack Query v5 — "Quản Gia" Lo Việc Dữ Liệu (Server State Management) 📡

Chào bạn! Khi làm web, việc đau đầu nhất thường không phải là vẽ giao diện, mà là đi xin dữ liệu từ máy chủ (fetch data) rồi hiển thị lên màn hình sao cho mượt. TanStack Query (trước đây gọi là React Query) chính là một vị "quản gia" siêu xịn giúp bạn lo toàn bộ mớ hỗn độn này.


Phân Biệt: Dữ Liệu Máy Chủ (Server State) vs Dữ Liệu Trình Duyệt (Client State) ⚖️

Để biết tại sao ta cần TanStack Query, hãy làm rõ hai khái niệm này trước nhé:

Tiêu chí Trí nhớ Mạng (Server State) Trí nhớ Tạm (Client State)
Lưu ở đâu? Trên Máy chủ (Database D1) Trong bộ nhớ của Trình duyệt
Ví dụ Danh sách giao dịch, thông tin nhóm Đang mở tab nào, thẻ đăng nhập (token)
Đặc điểm Rất dễ "thiu" (cũ) vì nhiều người có thể sửa cùng lúc Chỉ mình bạn biết, mình bạn thay đổi
Công cụ quản lý TanStack Query Zustand / useState

Nỗi Khổ Khi Phải Tự "Cày Chay" Dữ Liệu 😩

Nếu không có quản gia, bạn sẽ phải tự làm mọi thứ bằng tay với useStateuseEffect. Nhìn code nè:

// Code tự viết — Rất dài, nhàm chán và dễ sinh lỗi
function GroupPage() {
  const [group, setGroup] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  useEffect(() => {
    setLoading(true); // Bật icon xoay xoay
    fetch(`/api/groups/${groupId}`, {
      headers: { Authorization: `Bearer ${token}` }
    })
      .then(r => r.json())
      .then(setGroup)    // Có data thì cất vào
      .catch(setError)   // Lỗi thì báo
      .finally(() => setLoading(false)); // Tắt icon xoay xoay
  }, [groupId]);

  // HÀNG TÁ VẤN ĐỀ SINH RA:
  // - Đi trang nào bạn cũng phải lặp lại 3 cái state (data, loading, error) này.
  // - Không có bộ nhớ đệm (cache): Sang trang khác rồi quay lại, nó lại tải lại từ đầu (xoay xoay).
  // - Người dùng lướt qua tab khác rồi quay lại web, dữ liệu không tự làm mới.
  // - Nếu mạng lag, người dùng bấm qua lại 2 nhóm quá nhanh, dữ liệu sẽ bị "râu ông nọ cắm cằm bà kia".
  // - Đau đầu nhất: Thêm 1 khoản chi xong, làm sao hô hoán để cái cục "Tổng quỹ" nó tự cập nhật đây?
}

TanStack Query — Vị Cứu Tinh Xuất Hiện 🦸‍♂️

Quản gia này sẽ tự động lo hết: Lưu nháp dữ liệu (caching), gộp các lần gọi API bị trùng, tự đi lấy dữ liệu mới ngầm bên dưới, và tự tạo luôn trạng thái đang tải/lỗi cho bạn.

1. Khai trương (Setup)

// web/src/main.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

// Khởi tạo một quản gia mới
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 30_000,         // Dữ liệu lấy về được coi là "tươi mới" trong 30 giây (không cần gọi lại)
      retry: 1,                  // Nếu lỡ rớt mạng, cho phép thử gọi lại 1 lần nữa
    },
  },
});

// Bọc nó ra ngoài cùng ứng dụng
createRoot(document.getElementById('root')!).render(
  <QueryClientProvider client={queryClient}>
    <App />
  </QueryClientProvider>
);

2. useQuery — Đi xin dữ liệu về xem (Read)

// web/src/pages/GroupPage.tsx
function GroupPage() {
  const { groupId } = useParams();
  const { accessToken } = useAuthStore();

  const {
    data: group,          // Đây rồi! Dữ liệu đã về (Lúc đang tải thì nó là undefined)
    isLoading,            // Bằng true khi đang tải lần ĐẦU TIÊN
    isFetching,           // Bằng true mỗi khi đang tải ngầm (dù đã có dữ liệu cũ)
    isError,              // Có lỗi không?
    error,                // Lỗi cụ thể là gì?
    refetch,              // Gọi cái này nếu muốn chủ động bắt nó đi lấy dữ liệu mới ngay
  } = useQuery({
    queryKey: ['group', groupId],              // Chìa khóa đánh dấu món đồ này
    queryFn: () => api.groups.get(groupId!, accessToken!),
    enabled: !!groupId && !!accessToken,       // CÔNG TẮC: Chỉ cho phép chạy khi đã có ID nhóm và Thẻ đăng nhập
    staleTime: 1000 * 30,                      // Món này để 30s mới thiu
  });

  if (isLoading) return <Spinner />; // Xoay xoay...
  if (isError) return <ErrorMessage error={error} />; // Báo lỗi
  if (!group) return null;

  return <div>{group.name}</div>; // Hiện tên nhóm ra!
}

3. useMutation — Thay đổi dữ liệu (Thêm, Sửa, Xóa)

Bất cứ hành động nào làm đổi dữ liệu trên Máy chủ đều gọi chung là Mutation (Đột biến).

function AddTransactionModal({ groupId, onClose }) {
  const { accessToken } = useAuthStore();
  const queryClient = useQueryClient();

  const createTransaction = useMutation({
    // Việc cần làm: Lên server tạo giao dịch mới
    mutationFn: (data: CreateTransactionInput) =>
      api.transactions.create(groupId, data, accessToken!),

    // Nếu thành công rực rỡ:
    onSuccess: () => {
      // Hô to: "Ê quản gia, dữ liệu cũ rồi, quăng đi lấy đồ mới về đê!" (Invalidate)
      queryClient.invalidateQueries({ queryKey: ['transactions', groupId] }); // Làm mới danh sách thu chi
      queryClient.invalidateQueries({ queryKey: ['group', groupId] });        // Làm mới luôn cả tổng quỹ
      onClose(); // Tắt bảng nhập liệu
    },

    // Nếu toang:
    onError: (error) => {
      alert(error.message); // Báo lỗi cho khách biết
    },
  });

  const handleSubmit = (data) => {
    createTransaction.mutate(data); // Bấm nút là chạy hàm này!
  };

  return (
    <form onSubmit={handleSubmit}>
      {/* ... (Các ô nhập liệu) ... */}
      <button
        type="submit"
        disabled={createTransaction.isPending}  // Đang gửi thì khóa nút lại, chống bấm 2 lần
      >
        {createTransaction.isPending ? 'Đang lưu...' : 'Thêm giao dịch'}
      </button>
    </form>
  );
}

Nghệ Thuật Đặt Tên "Chìa Khóa" (Query Key Strategy) 🗝️

Query Key giống như cái nhãn dán trên hộp thức ăn trong tủ lạnh vậy. TanStack Query dùng nhãn này để:

  • Tìm xem thức ăn có sẵn trong tủ không.
  • Tránh việc 2 người cùng lúc chạy đi mua 1 món (Gộp request).
  • Chỉ đích danh hộp nào đã "thiu" để đi mua hộp mới.
// Các nhãn dán quy ước trong dự án này (Nó là một Mảng nhé):
['group', groupId]              // Thông tin chung của một nhóm
['group-members', groupId]      // Danh sách anh em trong nhóm
['transactions', groupId]       // Lịch sử thu chi của nhóm
['categories', groupId]         // Các hạng mục (ăn uống, đi lại...)
['groups']                      // Danh sách TẤT CẢ các nhóm mình tham gia

Cách ném đồ đi (Invalidation)

// Vứt đích danh 1 hộp: Chỉ làm mới nhóm có ID là groupId
queryClient.invalidateQueries({ queryKey: ['group', groupId] });

// Vứt cả dòng họ: Làm mới TẤT CẢ những thứ gì bắt đầu bằng chữ 'group'
queryClient.invalidateQueries({ queryKey: ['group'] });
// → Nó sẽ xóa: ['group'], ['group', 'abc']
// Nhưng KHÔNG xóa ['group-members', 'abc'] vì khác họ.

Vòng Đời Của Một Món Đồ Trong Tủ Lạnh (Caching) ♻️

1. Lần đầu gọi useQuery(['group', 'nhom-1']):
   Mở tủ thấy trống rỗng (Cache miss) → Chạy đi mua (Fetch API) → Cất vào tủ.

2. Bạn tắt cái màn hình đó đi (Component unmount):
   Đồ ăn vẫn cứ để trong tủ lạnh, chưa vứt vội (sống được 5 phút theo mặc định).

3. Bạn quay lại màn hình đó, lại gọi useQuery(['group', 'nhom-1']):
   Thấy có đồ trong tủ! (Cache hit) → Dọn ra ăn ngay lập tức (KHÔNG có icon xoay xoay).
   Nếu xem đồng hồ thấy đồ ăn để hơn 30s (đã thiu) → Cứ dọn ra cho khách ăn tạm, đồng thời LÉN chạy đi mua hộp mới bù vào.

4. Bấm nút: queryClient.invalidateQueries(['group', 'nhom-1']):
   Chủ động dán nhãn "ĐỒ THIU RỒI" → Nếu khách đang ngồi bàn ăn, lập tức đi mua đồ mới về thay.

Tuyệt Chiêu "Ăn Đồ Cũ, Chờ Đồ Mới" (Stale-While-Revalidate) 🍜

Với cài đặt staleTime = 30s (30s mới thiu):

  • Khi bạn chuyển qua trang khác rồi quay lại trang cũ.
  • TanStack Query sẽ bưng ngay cái dữ liệu cũ trong bộ nhớ đệm ra cho bạn xem (Bạn không hề thấy màn hình chớp chớp tải lại hay xoay xoay gì cả).
  • Ngầm bên dưới, nó đang đi hỏi API xem có dữ liệu gì mới không.
  • Nếu có, nó tự âm thầm đắp phần mới vào (Mượt mà không một vết xước). Đây là trải nghiệm UX đỉnh cao!

Đi Chợ Đón Đầu (Prefetching) 🔮

Đừng đợi người ta gọi món rồi mới đi chợ. Thấy khách rục rịch là đi mua luôn!

// Lấy dữ liệu trước cả khi người dùng bấm qua trang!
function GroupCard({ group }) {
  const queryClient = useQueryClient();

  const handleMouseEnter = () => {
    // Chỉ cần chuột người dùng LƯỚT QUA (hover) cái nút,
    // mình âm thầm chạy đi lấy data của nhóm đó về cất tủ luôn.
    queryClient.prefetchQuery({
      queryKey: ['group', group.id],
      queryFn: () => api.groups.get(group.id, accessToken!),
    });
  };

  return (
    // Rê chuột vào là nó lấy data. Bấm vào là có data hiện ra tức thì!
    <div onMouseEnter={handleMouseEnter}>
      <Link to={`/groups/${group.id}`}>{group.name}</Link>
    </div>
  );
}

Tải Đa Luồng (Song song) 🏎️

function GroupPage() {
  // Thay vì lấy cái A xong mới lấy cái B, ta cứ gọi cả 3 cái cùng lúc.
  // Chúng nó sẽ chạy đua song song với nhau.

  const { data: group } = useQuery({
    queryKey: ['group', groupId],
    queryFn: () => api.groups.get(groupId!, accessToken!),
  });

  const { data: members = [] } = useQuery({
    queryKey: ['group-members', groupId],
    queryFn: () => api.groups.members(groupId!, accessToken!),
  });

  const { data: transactions = [] } = useQuery({
    queryKey: ['transactions', groupId],
    queryFn: () => api.transactions.list(groupId!, accessToken!),
  });

  // Giao diện sẽ tự hiện lên khi các anh chàng này chạy xong!
}