TanStack Query — Quản Lý Server State Cho React
TanStack Query xử lý cache, refetch, stale management, pagination, optimistic update cho server state. Không cần Redux cho dữ liệu API.
TanStack Query (React Query) là thư viện quản lý server state cho React. Nó giải quyết: cache, background refetch, stale management, pagination, optimistic update — những thứ Redux/Zustand không làm tốt.
import { useQuery } from '@tanstack/react-query'
function Posts() {
const { data, isLoading, error } = useQuery({
queryKey: ['posts'],
queryFn: () => fetch('/api/posts').then(r => r.json()),
})
if (isLoading) return <Spinner />
if (error) return <Error message={error.message} />
return <PostList posts={data} />
}typescriptKhông useEffect, không useState, không manual loading/error state.
Vấn Đề Server State#
Server state khác client state:
| Client State | Server State |
|---|---|
| Theme, modal, form input | Dữ liệu từ API |
| Chỉ UI quan tâm | Nhiều component dùng chung |
| Sync ngay | Có thể stale |
| Không ai khác sửa | Người dùng khác có thể sửa |
Với useEffect + useState thủ công:
// ❌ Phải tự làm mọi thứ
function Posts() {
const [data, setData] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
setLoading(true)
fetch('/api/posts')
.then(r => r.json())
.then(d => { setData(d); setLoading(false) })
.catch(e => { setError(e); setLoading(false) })
}, [])
// ❌ Không cache: chuyển trang → gọi lại API
// ❌ Không background refetch
// ❌ Không stale management
// ❌ Loading flash mỗi lần
}typescriptTanStack Query xử lý tất cả.
Core Concepts#
useQuery#
const { data, isLoading, isError, error, isFetching, refetch } = useQuery({
queryKey: ['posts'], // Key duy nhất cho cache
queryFn: () => fetchPosts(), // Hàm fetch dữ liệu
staleTime: 5 * 60 * 1000, // 5 phút mới stale
gcTime: 30 * 60 * 1000, // Giữ cache 30 phút (trước là cacheTime)
refetchOnWindowFocus: true, // Tự động refetch khi focus tab
retry: 3, // Retry 3 lần khi lỗi
enabled: true, // Conditional fetching
})typescriptQuery Key#
// Key đơn giản
useQuery({ queryKey: ['posts'], queryFn: ... })
// Key với params — tự động refetch khi key thay đổi
useQuery({
queryKey: ['posts', { page, limit, search }],
queryFn: () => fetchPosts({ page, limit, search }),
})
// Key best practice: ['domain', 'action', ...params]
['posts'] // Tất cả posts
['posts', postId] // Một post
['posts', postId, 'comments'] // Comments của một post
['users', userId, 'profile'] // Profile của usertypescriptuseMutation#
const queryClient = useQueryClient()
const mutation = useMutation({
mutationFn: (newPost) =>
fetch('/api/posts', {
method: 'POST',
body: JSON.stringify(newPost),
}).then(r => r.json()),
onSuccess: (data) => {
// Invalidate cache → tự động refetch
queryClient.invalidateQueries({ queryKey: ['posts'] })
// Hoặc set cache trực tiếp (optimistic)
queryClient.setQueryData(['posts', data.id], data)
},
onError: (error) => {
toast.error(`Failed: ${error.message}`)
},
})
// Dùng mutation
mutation.mutate({ title: 'New Post', content: '...' })
mutation.mutateAsync({ title: '...' }).then(data => ...)
// Trạng thái
mutation.isPending // Đang gửi
mutation.isError // Lỗi
mutation.isSuccess // Thành côngtypescriptPatterns#
Dependent Queries#
// Query này phụ thuộc kết quả query kia
const { data: user } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
})
const { data: posts } = useQuery({
queryKey: ['posts', user?.id],
queryFn: () => fetchPostsByUser(user!.id),
enabled: !!user, // Chỉ chạy khi có user
})typescriptParallel Queries#
// Tự động chạy song song
const usersQuery = useQuery({ queryKey: ['users'], queryFn: fetchUsers })
const postsQuery = useQuery({ queryKey: ['posts'], queryFn: fetchPosts })
const commentsQuery = useQuery({ queryKey: ['comments'], queryFn: fetchComments })
// Dùng useQueries cho array động
const postIds = [1, 2, 3, 4, 5]
const postsQueries = useQueries({
queries: postIds.map(id => ({
queryKey: ['posts', id],
queryFn: () => fetchPost(id),
})),
})typescriptPagination#
function PostsPage() {
const [page, setPage] = useState(1)
const { data, isFetching } = useQuery({
queryKey: ['posts', { page }],
queryFn: () => fetchPosts(page),
placeholderData: keepPreviousData,
// Giữ data cũ trong khi fetch trang mới
// → không bị loading flash
})
return (
<div>
{data?.posts.map(post => <PostCard key={post.id} post={post} />)}
{isFetching && <Spinner />}
<button onClick={() => setPage(p => p - 1)} disabled={page === 1}>
Previous
</button>
<span>Page {page}</span>
<button onClick={() => setPage(p => p + 1)} disabled={!data?.hasMore}>
Next
</button>
</div>
)
}typescriptInfinite Scroll#
function InfinitePosts() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['posts'],
queryFn: ({ pageParam = 1 }) => fetchPosts(pageParam),
getNextPageParam: (lastPage) =>
lastPage.hasMore ? lastPage.page + 1 : undefined,
initialPageParam: 1,
})
return (
<div>
{data?.pages.map(page =>
page.posts.map(post => <PostCard key={post.id} post={post} />)
)}
<button
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
>
{isFetchingNextPage ? 'Loading...' : hasNextPage ? 'Load More' : 'All loaded'}
</button>
<div ref={sentinelRef} /> {/* Intersection Observer */}
</div>
)
}typescriptOptimistic Update#
const mutation = useMutation({
mutationFn: (updatedTodo) =>
fetch(`/api/todos/${updatedTodo.id}`, {
method: 'PATCH',
body: JSON.stringify(updatedTodo),
}).then(r => r.json()),
// Khi mutate, cập nhật cache ngay — trước khi API trả về
onMutate: async (newTodo) => {
// Cancel refetch đang chạy để tránh overwrite
await queryClient.cancelQueries({ queryKey: ['todos'] })
// Snap old value để rollback nếu lỗi
const previousTodos = queryClient.getQueryData(['todos'])
// Optimistic update
queryClient.setQueryData(['todos'], (old) =>
old?.map(todo =>
todo.id === newTodo.id ? { ...todo, ...newTodo } : todo
)
)
return { previousTodos } // Pass cho onError
},
// Nếu lỗi → rollback
onError: (err, newTodo, context) => {
queryClient.setQueryData(['todos'], context?.previousTodos)
toast.error('Update failed — rolled back')
},
// Refetch để đảm bảo UI khớp server
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})typescriptCache Configuration#
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 phút — dữ liệu được coi là fresh
gcTime: 30 * 60 * 1000, // Giữ cache 30 phút sau khi unmount
refetchOnWindowFocus: true, // Refetch khi focus tab
refetchOnReconnect: true, // Refetch khi online lại
retry: 3,
retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 30000),
},
},
})typescriptPer-query override#
// Dữ liệu ổn định — ít refetch
useQuery({
queryKey: ['settings'],
queryFn: fetchSettings,
staleTime: 30 * 60 * 1000, // 30 phút
refetchOnWindowFocus: false,
})
// Dữ liệu real-time — luôn fresh
useQuery({
queryKey: ['notifications'],
queryFn: fetchNotifications,
staleTime: 0, // Luôn stale → refetch khi focus
refetchInterval: 10000, // Poll mỗi 10s
})typescriptDevTools#
npm install @tanstack/react-query-devtoolsbashimport { ReactQueryDevtools } from '@tanstack/react-query-devtools'
function App() {
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
)
}typescriptDevTools cho phép: inspect cache, manual refetch, reset query, xem trạng thái (fresh/stale/fetching).
Custom Hooks Pattern#
// hooks/usePosts.ts
export function usePosts() {
return useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
staleTime: 5 * 60 * 1000,
})
}
export function usePost(id: number) {
return useQuery({
queryKey: ['posts', id],
queryFn: () => fetchPost(id),
enabled: !!id,
})
}
export function useCreatePost() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: CreatePostInput) =>
fetch('/api/posts', {
method: 'POST',
body: JSON.stringify(data),
}).then(r => r.json()),
onSuccess: (newPost) => {
queryClient.setQueryData(['posts', newPost.id], newPost)
queryClient.invalidateQueries({ queryKey: ['posts'] })
toast.success('Post created!')
},
})
}typescriptKhi Nào Dùng TanStack Query?#
✅ Nên dùng#
- Bất kỳ React app nào gọi API
- Dashboard với nhiều data fetching
- Real-time UI cần sync với server
- App có pagination/infinite scroll
- Optimistic update cần rollback
❌ Không cần#
- App không gọi API (static site)
- Dùng tRPC (tRPC tích hợp React Query sẵn)
- Server-rendered app không có client fetch
Tổng Kết#
TanStack Query loại bỏ ~90% boilerplate data fetching trong React.
// Trước: useEffect + useState + loading + error + cache
function Posts() {
const [data, setData] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
fetch('/api/posts')
.then(r => r.json())
.then(setData)
.finally(() => setLoading(false))
}, [])
if (loading) return <Spinner />
return <PostsList posts={data} />
}
// Sau: useQuery
function Posts() {
const { data, isLoading } = useQuery({
queryKey: ['posts'],
queryFn: () => fetch('/api/posts').then(r => r.json()),
})
if (isLoading) return <Spinner />
return <PostsList posts={data} />
}
// + Cache, refetch, stale management, retry, devtools — miễn phí
## Tài liệu tham khảo
- [TanStack Query Documentation](https://tanstack.com/query/latest)
- [TanStack Query — useQuery Guide](https://tanstack.com/query/latest/docs/framework/react/guides/queries)
- [TanStack Query — useMutation Guide](https://tanstack.com/query/latest/docs/framework/react/guides/mutations)
- [TanStack Query — Optimistic Updates](https://tanstack.com/query/latest/docs/framework/react/guides/optimistic-updates)
- [TanStack Query — Infinite Queries](https://tanstack.com/query/latest/docs/framework/react/guides/infinite-queries)
- [TanStack Query Devtools](https://tanstack.com/query/latest/docs/framework/react/devtools)typescript