React Query (TanStack Query) simplifies server state management. Here's how to use it effectively.
Basic Setup#
1// lib/queryClient.ts
2import { QueryClient } from '@tanstack/react-query';
3
4export const queryClient = new QueryClient({
5 defaultOptions: {
6 queries: {
7 staleTime: 1000 * 60 * 5, // 5 minutes
8 gcTime: 1000 * 60 * 30, // 30 minutes (formerly cacheTime)
9 retry: 3,
10 refetchOnWindowFocus: true,
11 refetchOnReconnect: true,
12 },
13 mutations: {
14 retry: 1,
15 },
16 },
17});
18
19// App wrapper
20import { QueryClientProvider } from '@tanstack/react-query';
21import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
22
23function App({ children }: { children: React.ReactNode }) {
24 return (
25 <QueryClientProvider client={queryClient}>
26 {children}
27 <ReactQueryDevtools initialIsOpen={false} />
28 </QueryClientProvider>
29 );
30}Query Hooks#
1// hooks/useUsers.ts
2import { useQuery, useSuspenseQuery } from '@tanstack/react-query';
3
4// Query keys factory
5export const userKeys = {
6 all: ['users'] as const,
7 lists: () => [...userKeys.all, 'list'] as const,
8 list: (filters: UserFilters) => [...userKeys.lists(), filters] as const,
9 details: () => [...userKeys.all, 'detail'] as const,
10 detail: (id: string) => [...userKeys.details(), id] as const,
11};
12
13// Query functions
14async function fetchUsers(filters: UserFilters): Promise<User[]> {
15 const params = new URLSearchParams(filters as Record<string, string>);
16 const response = await fetch(`/api/users?${params}`);
17
18 if (!response.ok) {
19 throw new Error('Failed to fetch users');
20 }
21
22 return response.json();
23}
24
25async function fetchUser(id: string): Promise<User> {
26 const response = await fetch(`/api/users/${id}`);
27
28 if (!response.ok) {
29 throw new Error('Failed to fetch user');
30 }
31
32 return response.json();
33}
34
35// Hooks
36export function useUsers(filters: UserFilters = {}) {
37 return useQuery({
38 queryKey: userKeys.list(filters),
39 queryFn: () => fetchUsers(filters),
40 });
41}
42
43export function useUser(id: string) {
44 return useQuery({
45 queryKey: userKeys.detail(id),
46 queryFn: () => fetchUser(id),
47 enabled: !!id,
48 });
49}
50
51// Suspense version
52export function useUserSuspense(id: string) {
53 return useSuspenseQuery({
54 queryKey: userKeys.detail(id),
55 queryFn: () => fetchUser(id),
56 });
57}
58
59// Component usage
60function UserList() {
61 const { data: users, isLoading, error } = useUsers({ status: 'active' });
62
63 if (isLoading) return <Spinner />;
64 if (error) return <Error message={error.message} />;
65
66 return (
67 <ul>
68 {users?.map((user) => (
69 <li key={user.id}>{user.name}</li>
70 ))}
71 </ul>
72 );
73}Mutations#
1// hooks/useUserMutations.ts
2import { useMutation, useQueryClient } from '@tanstack/react-query';
3
4async function createUser(data: CreateUserInput): Promise<User> {
5 const response = await fetch('/api/users', {
6 method: 'POST',
7 headers: { 'Content-Type': 'application/json' },
8 body: JSON.stringify(data),
9 });
10
11 if (!response.ok) {
12 throw new Error('Failed to create user');
13 }
14
15 return response.json();
16}
17
18export function useCreateUser() {
19 const queryClient = useQueryClient();
20
21 return useMutation({
22 mutationFn: createUser,
23 onSuccess: (newUser) => {
24 // Invalidate and refetch
25 queryClient.invalidateQueries({ queryKey: userKeys.lists() });
26
27 // Or update cache directly
28 queryClient.setQueryData(userKeys.detail(newUser.id), newUser);
29 },
30 onError: (error) => {
31 console.error('Failed to create user:', error);
32 },
33 });
34}
35
36export function useUpdateUser() {
37 const queryClient = useQueryClient();
38
39 return useMutation({
40 mutationFn: ({ id, data }: { id: string; data: UpdateUserInput }) =>
41 updateUser(id, data),
42 onMutate: async ({ id, data }) => {
43 // Cancel outgoing refetches
44 await queryClient.cancelQueries({ queryKey: userKeys.detail(id) });
45
46 // Snapshot previous value
47 const previousUser = queryClient.getQueryData(userKeys.detail(id));
48
49 // Optimistically update
50 queryClient.setQueryData(userKeys.detail(id), (old: User) => ({
51 ...old,
52 ...data,
53 }));
54
55 return { previousUser };
56 },
57 onError: (err, { id }, context) => {
58 // Rollback on error
59 queryClient.setQueryData(userKeys.detail(id), context?.previousUser);
60 },
61 onSettled: (_, __, { id }) => {
62 // Always refetch after error or success
63 queryClient.invalidateQueries({ queryKey: userKeys.detail(id) });
64 },
65 });
66}
67
68// Usage
69function CreateUserForm() {
70 const { mutate, isPending, error } = useCreateUser();
71
72 const handleSubmit = (data: CreateUserInput) => {
73 mutate(data, {
74 onSuccess: (user) => {
75 toast.success('User created!');
76 router.push(`/users/${user.id}`);
77 },
78 });
79 };
80
81 return (
82 <form onSubmit={handleSubmit}>
83 {/* form fields */}
84 <button type="submit" disabled={isPending}>
85 {isPending ? 'Creating...' : 'Create User'}
86 </button>
87 {error && <p className="error">{error.message}</p>}
88 </form>
89 );
90}Infinite Queries#
1// hooks/useInfinitePosts.ts
2import { useInfiniteQuery } from '@tanstack/react-query';
3
4interface PostsPage {
5 posts: Post[];
6 nextCursor: string | null;
7}
8
9async function fetchPosts({ pageParam = null }): Promise<PostsPage> {
10 const url = pageParam
11 ? `/api/posts?cursor=${pageParam}`
12 : '/api/posts';
13
14 const response = await fetch(url);
15 return response.json();
16}
17
18export function useInfinitePosts() {
19 return useInfiniteQuery({
20 queryKey: ['posts', 'infinite'],
21 queryFn: fetchPosts,
22 initialPageParam: null,
23 getNextPageParam: (lastPage) => lastPage.nextCursor,
24 getPreviousPageParam: (firstPage) => firstPage.prevCursor,
25 });
26}
27
28// Component with infinite scroll
29function PostFeed() {
30 const {
31 data,
32 fetchNextPage,
33 hasNextPage,
34 isFetchingNextPage,
35 } = useInfinitePosts();
36
37 const posts = data?.pages.flatMap((page) => page.posts) ?? [];
38
39 // Intersection Observer for infinite scroll
40 const loadMoreRef = useRef<HTMLDivElement>(null);
41
42 useEffect(() => {
43 const observer = new IntersectionObserver(
44 (entries) => {
45 if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
46 fetchNextPage();
47 }
48 },
49 { threshold: 0.1 }
50 );
51
52 if (loadMoreRef.current) {
53 observer.observe(loadMoreRef.current);
54 }
55
56 return () => observer.disconnect();
57 }, [hasNextPage, isFetchingNextPage, fetchNextPage]);
58
59 return (
60 <div>
61 {posts.map((post) => (
62 <PostCard key={post.id} post={post} />
63 ))}
64
65 <div ref={loadMoreRef}>
66 {isFetchingNextPage && <Spinner />}
67 </div>
68 </div>
69 );
70}Prefetching#
1// Prefetch on hover
2function UserLink({ userId }: { userId: string }) {
3 const queryClient = useQueryClient();
4
5 const prefetchUser = () => {
6 queryClient.prefetchQuery({
7 queryKey: userKeys.detail(userId),
8 queryFn: () => fetchUser(userId),
9 staleTime: 1000 * 60 * 5, // 5 minutes
10 });
11 };
12
13 return (
14 <Link
15 href={`/users/${userId}`}
16 onMouseEnter={prefetchUser}
17 onFocus={prefetchUser}
18 >
19 View User
20 </Link>
21 );
22}
23
24// Server-side prefetching (Next.js)
25// app/users/[id]/page.tsx
26import { dehydrate, HydrationBoundary } from '@tanstack/react-query';
27import { getQueryClient } from '@/lib/queryClient';
28
29export default async function UserPage({ params }: { params: { id: string } }) {
30 const queryClient = getQueryClient();
31
32 await queryClient.prefetchQuery({
33 queryKey: userKeys.detail(params.id),
34 queryFn: () => fetchUser(params.id),
35 });
36
37 return (
38 <HydrationBoundary state={dehydrate(queryClient)}>
39 <UserProfile userId={params.id} />
40 </HydrationBoundary>
41 );
42}Dependent Queries#
1// Query that depends on another query
2function useUserPosts(userId: string) {
3 const { data: user } = useUser(userId);
4
5 return useQuery({
6 queryKey: ['users', userId, 'posts'],
7 queryFn: () => fetchUserPosts(userId),
8 enabled: !!user, // Only fetch when user is loaded
9 });
10}
11
12// Parallel queries
13function useDashboardData() {
14 const results = useQueries({
15 queries: [
16 {
17 queryKey: ['dashboard', 'stats'],
18 queryFn: fetchStats,
19 },
20 {
21 queryKey: ['dashboard', 'recent-orders'],
22 queryFn: fetchRecentOrders,
23 },
24 {
25 queryKey: ['dashboard', 'notifications'],
26 queryFn: fetchNotifications,
27 },
28 ],
29 });
30
31 const isLoading = results.some((r) => r.isLoading);
32 const isError = results.some((r) => r.isError);
33
34 return {
35 stats: results[0].data,
36 orders: results[1].data,
37 notifications: results[2].data,
38 isLoading,
39 isError,
40 };
41}Query Filters and Selectors#
1// Select specific data
2function useUserName(userId: string) {
3 return useQuery({
4 queryKey: userKeys.detail(userId),
5 queryFn: () => fetchUser(userId),
6 select: (user) => user.name,
7 });
8}
9
10// Transform data
11function useUserFullName(userId: string) {
12 return useQuery({
13 queryKey: userKeys.detail(userId),
14 queryFn: () => fetchUser(userId),
15 select: (user) => `${user.firstName} ${user.lastName}`,
16 });
17}
18
19// Memoized selector
20const selectActiveUsers = (users: User[]) =>
21 users.filter((u) => u.status === 'active');
22
23function useActiveUsers() {
24 return useQuery({
25 queryKey: userKeys.all,
26 queryFn: fetchAllUsers,
27 select: selectActiveUsers,
28 });
29}Best Practices#
Structure:
✓ Use query key factories
✓ Colocate queries with features
✓ Abstract fetch functions
✓ Type everything
Performance:
✓ Use staleTime appropriately
✓ Prefetch on hover/focus
✓ Use select for derived data
✓ Avoid over-fetching
Mutations:
✓ Implement optimistic updates
✓ Handle errors gracefully
✓ Invalidate related queries
✓ Show loading states
Conclusion#
React Query handles server state elegantly. Use query keys factories for organization, implement optimistic updates for responsiveness, and leverage prefetching for perceived performance. The combination of automatic caching, background updates, and devtools makes data fetching straightforward.