TanStack Query Setup
Manage server state with powerful caching, background updates, and optimistic UI.
Core Workflow Install and configure: Set up QueryClient Create queries: Define data fetching hooks Add mutations: Handle data modifications Enable caching: Configure stale times Implement optimistic updates: Instant UI feedback Add infinite queries: Pagination and infinite scroll Installation npm install @tanstack/react-query @tanstack/react-query-devtools
Provider Setup Next.js App Router // app/providers.tsx 'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import { useState } from 'react';
export function Providers({ children }: { children: React.ReactNode }) { const [queryClient] = useState( () => new QueryClient({ defaultOptions: { queries: { staleTime: 60 * 1000, // 1 minute gcTime: 5 * 60 * 1000, // 5 minutes (formerly cacheTime) retry: 1, refetchOnWindowFocus: false, }, }, }) );
return (
// app/layout.tsx import { Providers } from './providers';
export default function RootLayout({ children }: { children: React.ReactNode }) { return (
Basic Queries Simple Query // hooks/useUsers.ts import { useQuery } from '@tanstack/react-query';
interface User { id: string; name: string; email: string; }
async function fetchUsers(): Promise
export function useUsers() { return useQuery({ queryKey: ['users'], queryFn: fetchUsers, }); }
// Usage function UsersList() { const { data: users, isLoading, error } = useUsers();
if (isLoading) return
return (
-
{users?.map((user) => (
- {user.name} ))}
Query with Parameters // hooks/useUser.ts import { useQuery } from '@tanstack/react-query';
async function fetchUser(userId: string): Promise/api/users/${userId});
if (!response.ok) {
throw new Error('Failed to fetch user');
}
return response.json();
}
export function useUser(userId: string) { return useQuery({ queryKey: ['users', userId], queryFn: () => fetchUser(userId), enabled: !!userId, // Only fetch when userId exists }); }
// Usage function UserProfile({ userId }: { userId: string }) { const { data: user, isLoading } = useUser(userId);
if (isLoading) return
return
Query with Filters // hooks/useProducts.ts interface ProductFilters { category?: string; minPrice?: number; maxPrice?: number; search?: string; }
async function fetchProducts(filters: ProductFilters): Promise
const response = await fetch(/api/products?${params});
return response.json();
}
export function useProducts(filters: ProductFilters) { return useQuery({ queryKey: ['products', filters], queryFn: () => fetchProducts(filters), placeholderData: (previousData) => previousData, // Keep previous data while fetching }); }
Mutations Basic Mutation // hooks/useCreateUser.ts import { useMutation, useQueryClient } from '@tanstack/react-query';
interface CreateUserDto { name: string; email: string; }
async function createUser(data: CreateUserDto): Promise
if (!response.ok) { throw new Error('Failed to create user'); }
return response.json(); }
export function useCreateUser() { const queryClient = useQueryClient();
return useMutation({ mutationFn: createUser, onSuccess: (newUser) => { // Invalidate and refetch users list queryClient.invalidateQueries({ queryKey: ['users'] }); }, onError: (error) => { console.error('Failed to create user:', error); }, }); }
// Usage function CreateUserForm() { const { mutate, isPending, isError, error } = useCreateUser();
const handleSubmit = (e: React.FormEvent
mutate({
name: formData.get('name') as string,
email: formData.get('email') as string,
});
};
return (
); }Update and Delete // hooks/useUpdateUser.ts export function useUpdateUser() { const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ id, data }: { id: string; data: Partial/api/users/${id}, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
return response.json();
},
onSuccess: (updatedUser) => {
// Update the single user cache
queryClient.setQueryData(['users', updatedUser.id], updatedUser);
// Invalidate the list
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
}
// hooks/useDeleteUser.ts export function useDeleteUser() { const queryClient = useQueryClient();
return useMutation({
mutationFn: async (userId: string) => {
await fetch(/api/users/${userId}, { method: 'DELETE' });
return userId;
},
onSuccess: (deletedId) => {
// Remove from cache
queryClient.removeQueries({ queryKey: ['users', deletedId] });
// Invalidate list
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
}
Optimistic Updates List Update // hooks/useToggleTodo.ts export function useToggleTodo() { const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ id, completed }: { id: string; completed: boolean }) => {
const response = await fetch(/api/todos/${id}, {
method: 'PATCH',
body: JSON.stringify({ completed }),
});
return response.json();
},
// Optimistic update
onMutate: async ({ id, completed }) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ['todos'] });
// Snapshot previous value
const previousTodos = queryClient.getQueryData<Todo[]>(['todos']);
// Optimistically update
queryClient.setQueryData<Todo[]>(['todos'], (old) =>
old?.map((todo) =>
todo.id === id ? { ...todo, completed } : todo
)
);
// Return context for rollback
return { previousTodos };
},
// Rollback on error
onError: (err, variables, context) => {
if (context?.previousTodos) {
queryClient.setQueryData(['todos'], context.previousTodos);
}
},
// Refetch after success or error
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
}); }
Create with Optimistic Add export function useCreateTodo() { const queryClient = useQueryClient();
return useMutation({ mutationFn: async (text: string) => { const response = await fetch('/api/todos', { method: 'POST', body: JSON.stringify({ text }), }); return response.json(); },
onMutate: async (text) => {
await queryClient.cancelQueries({ queryKey: ['todos'] });
const previousTodos = queryClient.getQueryData<Todo[]>(['todos']);
// Add optimistic todo with temp id
const optimisticTodo: Todo = {
id: `temp-${Date.now()}`,
text,
completed: false,
};
queryClient.setQueryData<Todo[]>(['todos'], (old) => [
...(old || []),
optimisticTodo,
]);
return { previousTodos, optimisticTodo };
},
onError: (err, text, context) => {
if (context?.previousTodos) {
queryClient.setQueryData(['todos'], context.previousTodos);
}
},
onSuccess: (newTodo, text, context) => {
// Replace optimistic todo with real one
queryClient.setQueryData<Todo[]>(['todos'], (old) =>
old?.map((todo) =>
todo.id === context?.optimisticTodo.id ? newTodo : todo
)
);
},
}); }
Infinite Queries Cursor-Based Pagination // hooks/useInfinitePosts.ts import { useInfiniteQuery } from '@tanstack/react-query';
interface PostsPage { posts: Post[]; nextCursor?: string; }
async function fetchPosts({ pageParam }: { pageParam?: string }): Promise/api/posts?cursor=${pageParam}
: '/api/posts';
const response = await fetch(url); return response.json(); }
export function useInfinitePosts() { return useInfiniteQuery({ queryKey: ['posts'], queryFn: fetchPosts, initialPageParam: undefined, getNextPageParam: (lastPage) => lastPage.nextCursor, }); }
// Usage with intersection observer function PostsFeed() { const { data, fetchNextPage, hasNextPage, isFetchingNextPage, } = useInfinitePosts();
const loadMoreRef = useRef
useEffect(() => { const observer = new IntersectionObserver( (entries) => { if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) { fetchNextPage(); } }, { threshold: 0.1 } );
if (loadMoreRef.current) {
observer.observe(loadMoreRef.current);
}
return () => observer.disconnect();
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
return (
<div ref={loadMoreRef} className="h-10">
{isFetchingNextPage && <Spinner />}
</div>
</div>
); }
Offset-Based Pagination
export function useInfiniteProducts() {
return useInfiniteQuery({
queryKey: ['products'],
queryFn: async ({ pageParam = 0 }) => {
const response = await fetch(/api/products?offset=${pageParam}&limit=20);
return response.json();
},
initialPageParam: 0,
getNextPageParam: (lastPage, allPages) => {
// Stop if less than limit returned
if (lastPage.products.length < 20) return undefined;
return allPages.length * 20;
},
});
}
Query Factories Organized Query Keys // lib/queries/users.ts import { queryOptions } from '@tanstack/react-query';
export const userQueries = { all: () => queryOptions({ queryKey: ['users'], queryFn: fetchUsers, }),
detail: (id: string) => queryOptions({ queryKey: ['users', id], queryFn: () => fetchUser(id), }),
list: (filters: UserFilters) => queryOptions({ queryKey: ['users', 'list', filters], queryFn: () => fetchUsers(filters), }),
posts: (userId: string) => queryOptions({ queryKey: ['users', userId, 'posts'], queryFn: () => fetchUserPosts(userId), }), };
// Usage function UserProfile({ userId }: { userId: string }) { const userQuery = useQuery(userQueries.detail(userId)); const postsQuery = useQuery(userQueries.posts(userId)); // ... }
// Invalidation queryClient.invalidateQueries({ queryKey: ['users'] }); // All user queries queryClient.invalidateQueries({ queryKey: ['users', userId] }); // Specific user
Prefetching On Hover function UserLink({ userId, children }: { userId: string; children: React.ReactNode }) { const queryClient = useQueryClient();
const prefetch = () => { queryClient.prefetchQuery(userQueries.detail(userId)); };
return ( /users/${userId}} onMouseEnter={prefetch} onFocus={prefetch} > {children} ); }
In Server Components // app/users/page.tsx import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query'; import { userQueries } from '@/lib/queries/users'; import { UsersList } from './UsersList';
export default async function UsersPage() { const queryClient = new QueryClient();
await queryClient.prefetchQuery(userQueries.all());
return (
Dependent Queries function UserPosts({ userId }: { userId: string }) { // First query const userQuery = useQuery({ queryKey: ['users', userId], queryFn: () => fetchUser(userId), });
// Dependent query - only runs when user is loaded const postsQuery = useQuery({ queryKey: ['users', userId, 'posts'], queryFn: () => fetchUserPosts(userId), enabled: !!userQuery.data, // Wait for user });
if (userQuery.isLoading) return
return (
{userQuery.data?.name}'s Posts
{postsQuery.isLoading ? (Parallel Queries import { useQueries } from '@tanstack/react-query';
function Dashboard({ userIds }: { userIds: string[] }) { const userQueries = useQueries({ queries: userIds.map((id) => ({ queryKey: ['users', id], queryFn: () => fetchUser(id), })), });
const isLoading = userQueries.some((q) => q.isLoading); const users = userQueries.map((q) => q.data).filter(Boolean);
if (isLoading) return
return (
Best Practices Use query factories: Organized, reusable query options Set appropriate stale times: Balance freshness vs performance Optimistic updates: Instant UI feedback Prefetch on hover: Anticipate user navigation Use placeholderData: Show stale data while fetching Handle errors gracefully: Error boundaries and retry SSR with HydrationBoundary: Hydrate queries from server Separate queries and mutations: Clear data flow Output Checklist
Every TanStack Query implementation should include:
QueryClient with default options Provider with devtools Query factories for organization Proper query keys structure Mutations with invalidation Optimistic updates for UX Loading and error states Prefetching strategy SSR hydration (if using Next.js) Infinite queries for pagination
← 返回排行榜