Fetch vs TanStack Query
So sánh fetch và TanStack Query, khi nào cần gì, và bài toán thực tế với Image Agent
fetch là API có sẵn để gửi request. TanStack Query giải quyết bài toán ở tầng cao hơn: quản lý server state trên client. 2 thứ này không thay thế nhau -- TanStack Query dùng fetch bên trong. Hiểu rõ ranh giới giữa chúng để không tự viết lại những gì đã có sẵn.
| Khả năng | fetch thuần | TanStack Query |
|---|---|---|
| Gửi request | Có | Có (dùng fetch bên trong) |
| Cache response | Tự viết | Tự động theo query key |
| Deduplication | Tự viết | Tự động -- cùng key chỉ fetch 1 lần |
| Loading / error states | useState x3 | useQuery trả về sẵn |
| Infinite scroll | Tự quản lý page, concat, hasMore | useInfiniteQuery |
| Retry khi fail | Tự viết | Mặc định 3 lần với backoff |
| Refetch on focus | Tự viết listener | Mặc định bật |
| Cache invalidation | Tự viết | invalidateQueries() |
| Optimistic updates | Tự viết rollback logic | onMutate + onError rollback |
Vấn Đề Khi Dùng Fetch Cho UI
fetch gửi HTTP request, nhận response -- hết. Không cache, không retry, không deduplication. Khi dùng trong React, mỗi endpoint cần 3 useState (data, loading, error) + 1 useEffect. 10 endpoints = 30 state variables cùng pattern. Và khi UI phức tạp hơn, các vấn đề khác bắt đầu xuất hiện:
Duplicate requests
Sidebar hiển thị danh sách ảnh. Modal cũng cần data của ảnh đó. Gallery grid cũng fetch cùng endpoint. 3 component, 3 requests giống nhau -- lãng phí bandwidth và server resources.
Không có cache
Người dùng mở trang danh sách, click vào detail, bấm back. Fetch thuần gọi API lại từ đầu. Người dùng thấy loading spinner lần nữa cho data đã có 2 giây trước. Tự viết cache thì phải quản lý invalidation, expiry, memory -- phức tạp hơn tưởng.
Infinite scroll
Gallery cần load thêm ảnh khi người dùng scroll. Fetch thuần phải tự quản lý page number, concat results vào mảng cũ, track hasNextPage, xử lý loading giữa các page, error handling, retry khi fail giữa chừng, reset khi search query thay đổi. Code phình nhanh.
Data cũ (stale data)
Người dùng để tab mở 10 phút rồi quay lại. Data đã cũ nhưng UI vẫn hiển thị version cũ. Tự viết refetch-on-focus thì phải addEventListener cho visibilitychange, cleanup listener, throttle để không refetch quá nhiều.
Càng nhiều endpoint, càng nhiều boilerplate giống nhau. Và mỗi lần đều phải giải quyết lại cùng bài toán.
TanStack Query Giải Quyết Gì
TanStack Query là server state management layer cho client. Nó không thay thế fetch -- nó dùng fetch (hoặc bất kỳ async function nào) bên trong, rồi thêm 1 lớp quản lý phía trên.
Core concepts
Query key: mỗi query được định danh bằng 1 key. Cùng key = cùng data = share cache.
staleTime: data được coi là "tươi" trong bao lâu. Trong thời gian này, component mới mount sẽ dùng cache ngay, không fetch lại.
gcTime (garbage collection time): data không còn component nào dùng sẽ bị xóa khỏi cache sau thời gian này. Mặc định 5 phút.
useQuery: đọc data
Trước (fetch thuần) → Sau (TanStack Query):
// Fetch thuần: 3 useState + useEffect
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetch('/api/images').then(res => res.json()).then(setData).catch(setError).finally(() => setLoading(false));
}, []);
// TanStack Query: 1 dòng
const { data, isLoading, error } = useQuery({
queryKey: ['images'],
queryFn: () => fetch('/api/images').then(res => res.json()),
});
2 component dùng cùng query key → 1 request duy nhất, cả 2 nhận data từ cache.
useInfiniteQuery: infinite scroll
// Fetch thuần: tự quản lý page, concat, hasMore, loading
const [images, setImages] = useState([]);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [loading, setLoading] = useState(false);
async function loadMore() {
if (loading || !hasMore) return;
setLoading(true);
const res = await fetch(`/api/images?page=${page}`);
const newImages = await res.json();
setImages(prev => [...prev, ...newImages.data]);
setHasMore(newImages.hasNext);
setPage(prev => prev + 1);
setLoading(false);
}
// TanStack Query: page, concat, hasMore đều tự động
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
queryKey: ['images'],
queryFn: ({ pageParam = 1 }) =>
fetch(`/api/images?page=${pageParam}`).then(res => res.json()),
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
});
Component chỉ cần gọi fetchNextPage() khi người dùng scroll tới cuối.
useMutation: ghi data và sync UI
useQuery dùng cho đọc, useMutation dùng cho ghi (tạo, sửa, xóa). Sau khi mutation thành công, invalidate query liên quan để UI tự cập nhật:
const uploadMutation = useMutation({
mutationFn: (file: File) => uploadImage(file),
onSuccess: () => {
// Upload xong → gallery tự refetch
queryClient.invalidateQueries({ queryKey: ['images'] });
},
});
Optimistic updates cũng được hỗ trợ: cập nhật UI ngay khi người dùng thao tác, rollback nếu server trả lỗi. Không cần tự viết logic rollback.
Khi Nào Dùng Cái Nào
Fetch là đủ khi:
- Server-side: API routes, server components -- không có client state cần quản lý
- Request đơn lẻ, fire-and-forget: submit form, delete item -- không cần cache
- Streaming: SSE, EventSource -- pattern khác hoàn toàn, không phải query
- External API calls từ server: gọi Cloudflare, AI providers -- server-to-server không cần client cache
TanStack Query khi:
- Client-side data hiển thị cho người dùng: list, detail, search results
- Nhiều component dùng chung data: sidebar + grid + modal cùng cần images
- List với pagination hoặc infinite scroll: gallery, feed, search results
- Data thay đổi thường xuyên: cần refetch thông minh thay vì manual
- Mutation cần sync UI: upload xong thì gallery tự refresh
Không nên dùng TanStack Query khi:
- App chỉ có 1-2 endpoint đơn giản -- overhead không đáng
- Toàn bộ data fetching ở server side (RSC) -- không có client state
- Real-time data qua WebSocket -- dùng subscription pattern, không phải query
Case Study: Image Agent
Image Agent là ví dụ thực tế nơi fetch thuần không đủ. App có image library chứa hàng nghìn ảnh với react-virtuoso cho virtualized list, hybrid search (semantic + full-text) với kết quả thay đổi theo query, và nhiều view cùng hiển thị data ảnh (gallery grid, sidebar preview, detail modal). useInfiniteQuery quản lý việc load thêm ảnh khi scroll, query key ['images', { search }] cache kết quả tìm kiếm để người dùng quay lại query cũ không cần fetch lại, deduplication đảm bảo nhiều component dùng chung data chỉ tạo 1 request, và useMutation với invalidateQueries giữ gallery luôn đồng bộ sau khi upload ảnh mới qua pipeline Cloudflare → Voyage AI → Supabase.