React Query (TanStack Query)
Giới thiệu
TanStack Query (trước đây là React Query) là thư viện server state management mạnh nhất hiện nay. Nó xử lý fetching, caching, synchronizing và updating server state.
Client State vs Server State:
- Client State: theme, language, UI state (dùng useState/Redux)
- Server State: API data, cần fetch, cache, sync (dùng React Query)
Cài đặt
npm install @tanstack/react-query @tanstack/react-query-devtools
Setup
// main.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // Data cũ sau 5 phút
gcTime: 10 * 60 * 1000, // Xóa cache sau 10 phút không dùng
retry: 3, // Retry 3 lần khi fail
refetchOnWindowFocus: true, // Refetch khi focus lại tab
},
},
});
createRoot(document.getElementById('root')!).render(
<QueryClientProvider client={queryClient}>
<App />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
useQuery - Fetching Data
import { useQuery } from '@tanstack/react-query';
// Query function
async function fetchUser(id: number): Promise<User> {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) throw new Error('Network response was not ok');
return response.json();
}
// Sử dụng
function UserProfile({ userId }: { userId: number }) {
const {
data: user,
isLoading,
isError,
error,
isFetching, // true khi đang fetch (kể cả background)
isStale, // true khi data đã cũ
refetch, // Function để refetch thủ công
} = useQuery({
queryKey: ['user', userId], // Unique key - dùng để cache & invalidate
queryFn: () => fetchUser(userId),
enabled: userId > 0, // Chỉ fetch khi userId hợp lệ
staleTime: 30_000, // Override global staleTime
select: (data) => ({ // Transform data
...data,
fullName: `${data.firstName} ${data.lastName}`,
}),
});
if (isLoading) return <Skeleton />;
if (isError) return <Alert message={(error as Error).message} />;
return (
<div>
{isFetching && <span>Updating...</span>}
<h1>{user?.fullName}</h1>
<button onClick={() => refetch()}>Refresh</button>
</div>
);
}
Query Keys - Best Practices
// Query key xác định cache entry
// Khi key thay đổi → query mới được tạo
// Simple
useQuery({ queryKey: ['todos'], queryFn: getTodos });
// Với ID
useQuery({ queryKey: ['todo', todoId], queryFn: () => getTodo(todoId) });
// Với filters
useQuery({
queryKey: ['todos', { status: 'active', page: 1 }],
queryFn: () => getTodos({ status: 'active', page: 1 }),
});
// Query key factory pattern (best practice)
export const userKeys = {
all: ['users'] as const,
lists: () => [...userKeys.all, 'list'] as const,
list: (filters: UserFilters) => [...userKeys.lists(), filters] as const,
details: () => [...userKeys.all, 'detail'] as const,
detail: (id: number) => [...userKeys.details(), id] as const,
};
useQuery({ queryKey: userKeys.detail(userId), queryFn: ... });
useMutation - Thay đổi Data
import { useMutation, useQueryClient } from '@tanstack/react-query';
async function createTodo(todo: CreateTodoDto): Promise<Todo> {
const response = await fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(todo),
});
if (!response.ok) throw new Error('Failed to create todo');
return response.json();
}
function CreateTodoForm() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: createTodo,
// Optimistic update
onMutate: async (newTodo) => {
// Hủy queries đang chạy để tránh overwrite
await queryClient.cancelQueries({ queryKey: ['todos'] });
// Lưu snapshot state hiện tại
const previousTodos = queryClient.getQueryData<Todo[]>(['todos']);
// Optimistically update cache
queryClient.setQueryData<Todo[]>(['todos'], (old = []) => [
...old,
{ id: Date.now(), ...newTodo, status: 'pending' },
]);
return { previousTodos }; // Context để rollback
},
onError: (err, newTodo, context) => {
// Rollback khi có lỗi
queryClient.setQueryData(['todos'], context?.previousTodos);
toast.error('Failed to create todo');
},
onSuccess: (data) => {
// Invalidate để refetch
queryClient.invalidateQueries({ queryKey: ['todos'] });
toast.success('Todo created!');
},
onSettled: () => {
// Chạy dù thành công hay thất bại
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const form = e.target as HTMLFormElement;
mutation.mutate({
title: form.title.value,
description: form.description.value,
});
}
return (
<form onSubmit={handleSubmit}>
<input name="title" required />
<textarea name="description" />
<button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Creating...' : 'Create Todo'}
</button>
{mutation.isError && (
<p className="error">{(mutation.error as Error).message}</p>
)}
</form>
);
}
Pagination
import { useQuery, keepPreviousData } from '@tanstack/react-query';
async function fetchTodos(page: number, pageSize: number) {
const response = await fetch(
`/api/todos?page=${page}&pageSize=${pageSize}`
);
return response.json() as Promise<{ items: Todo[]; total: number }>;
}
function TodoList() {
const [page, setPage] = useState(1);
const pageSize = 10;
const { data, isLoading, isPlaceholderData } = useQuery({
queryKey: ['todos', { page, pageSize }],
queryFn: () => fetchTodos(page, pageSize),
placeholderData: keepPreviousData, // Giữ data cũ khi fetch trang mới
});
const totalPages = Math.ceil((data?.total ?? 0) / pageSize);
return (
<div>
{isLoading ? (
<Spinner />
) : (
<ul>
{data?.items.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
)}
<div className="pagination">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
>
Previous
</button>
<span>
Page {page} of {totalPages}
</span>
<button
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages || isPlaceholderData}
>
Next
</button>
</div>
</div>
);
}
Infinite Scroll
import { useInfiniteQuery } from '@tanstack/react-query';
import { useIntersection } from '@mantine/hooks'; // hoặc custom hook
async function fetchPosts({ pageParam = 1 }) {
const response = await fetch(`/api/posts?cursor=${pageParam}&limit=10`);
return response.json() as Promise<{
items: Post[];
nextCursor: number | null;
}>;
}
function InfinitePostList() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
initialPageParam: 1,
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
});
const bottomRef = useRef<HTMLDivElement>(null);
// Intersection Observer để auto-load
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
},
{ threshold: 0.1 }
);
if (bottomRef.current) observer.observe(bottomRef.current);
return () => observer.disconnect();
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
const allPosts = data?.pages.flatMap((page) => page.items) ?? [];
return (
<div>
{allPosts.map((post) => (
<PostCard key={post.id} post={post} />
))}
{isFetchingNextPage && <Spinner />}
<div ref={bottomRef} className="h-4" />
</div>
);
}
prefetchQuery & Cache Management
const queryClient = useQueryClient();
// Prefetch trước khi user navigate
async function prefetchUser(userId: number) {
await queryClient.prefetchQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
staleTime: 10_000,
});
}
// Invalidate - buộc refetch
queryClient.invalidateQueries({ queryKey: ['users'] });
// Set data trực tiếp
queryClient.setQueryData(['user', 1], updatedUser);
// Xóa cache
queryClient.removeQueries({ queryKey: ['user', userId] });
// Hover prefetch pattern
function UserLink({ userId, name }: { userId: number; name: string }) {
const queryClient = useQueryClient();
return (
<a
href={`/users/${userId}`}
onMouseEnter={() => prefetchUser(userId)} // Prefetch khi hover
>
{name}
</a>
);
}