Mastering React Query: Data Fetching Made Simple
Learn how to use React Query (TanStack Query) for efficient data fetching, caching, and synchronization in React applications.
- React
- React Query
- Data Fetching
- TypeScript
- API
React Query (now TanStack Query) revolutionizes how we handle server state in React applications. It provides powerful data fetching, caching, synchronization, and updating capabilities with minimal boilerplate. After implementing it in production applications like Unique Sports Group, I can confidently say it's a game-changer for data management.
Why React Query?
Traditional data fetching in React has several pain points:
- Manual loading states
- Error handling boilerplate
- Cache management complexity
- Refetching logic
- Optimistic updates complexity
React Query solves all of these elegantly.
Basic Setup
Install React Query:
npm install @tanstack/react-query
Wrap your app with QueryClientProvider:
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 10, // 10 minutes (formerly cacheTime)
},
},
});
function App() {
return (
<QueryClientProvider client={queryClient}>
<YourApp />
</QueryClientProvider>
);
}
Basic Data Fetching
The useQuery hook handles data fetching:
import { useQuery } from '@tanstack/react-query';
interface User {
id: string;
name: string;
}
interface UserProfileProps {
userId: string;
}
function UserProfile({ userId }: UserProfileProps) {
const { data, isLoading, error } = useQuery<User>({
queryKey: ['user', userId],
queryFn: () => fetch(`/api/users/${userId}`).then(res => res.json()),
});
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return <div>{data?.name}</div>;
}
Query Keys
Query keys uniquely identify queries and enable caching:
// Simple key
queryKey: ['users']
// Parameterized key
queryKey: ['user', userId]
// Complex key
queryKey: ['users', { filters: { status: 'active', page: 1 } }]
Data Mutations
Use useMutation for create, update, and delete operations:
import { useMutation, useQueryClient } from '@tanstack/react-query';
interface NewUser {
name: string;
}
interface User {
id: string;
name: string;
}
function CreateUser() {
const queryClient = useQueryClient();
const mutation = useMutation<User, Error, NewUser>({
mutationFn: (newUser) =>
fetch('/api/users', {
method: 'POST',
body: JSON.stringify(newUser),
}).then(res => res.json()),
onSuccess: () => {
// Invalidate and refetch users list
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
return (
<button
onClick={() => mutation.mutate({ name: 'John' })}
disabled={mutation.isPending}
>
{mutation.isPending ? 'Creating...' : 'Create User'}
</button>
);
}
Optimistic Updates
Update UI immediately before server confirms:
import { useMutation, useQueryClient } from '@tanstack/react-query';
interface Todo {
id: number;
text: string;
done: boolean;
}
function UpdateTodo() {
const queryClient = useQueryClient();
const mutation = useMutation<Todo, Error, Todo>({
mutationFn: updateTodo,
onMutate: async (newTodo) => {
// 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 === newTodo.id ? newTodo : todo) || []
);
return { previousTodos };
},
onError: (err, newTodo, context) => {
// Rollback on error
if (context?.previousTodos) {
queryClient.setQueryData(['todos'], context.previousTodos);
}
},
onSettled: () => {
// Refetch after error or success
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
return mutation;
}
Dependent Queries
Fetch data based on previous query results:
interface User {
id: string;
}
interface Post {
id: string;
title: string;
}
interface UserPostsProps {
userId: string;
}
function UserPosts({ userId }: UserPostsProps) {
const { data: user } = useQuery<User>({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
const { data: posts } = useQuery<Post[]>({
queryKey: ['posts', userId],
queryFn: () => fetchUserPosts(userId),
enabled: !!user, // Only fetch when user exists
});
return <div>{/* Render posts */}</div>;
}
Infinite Queries
Handle paginated data with infinite scrolling:
import { useInfiniteQuery } from '@tanstack/react-query';
interface Post {
id: string;
title: string;
}
interface PostsResponse {
posts: Post[];
hasNextPage: boolean;
}
function InfinitePosts() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery<PostsResponse>({
queryKey: ['posts'],
queryFn: ({ pageParam = 1 }) => fetchPosts(pageParam as number),
getNextPageParam: (lastPage, pages) =>
lastPage.hasNextPage ? pages.length + 1 : undefined,
initialPageParam: 1,
});
return (
<div>
{data?.pages.map((page, i) => (
<div key={i}>
{page.posts.map(post => (
<PostCard key={post.id} post={post} />
))}
</div>
))}
{hasNextPage && (
<button
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage}
>
{isFetchingNextPage ? 'Loading...' : 'Load More'}
</button>
)}
</div>
);
}
Custom Hooks
Create reusable query hooks:
// hooks/useUsers.ts
import { useQuery } from '@tanstack/react-query';
interface User {
id: string;
name: string;
}
interface UserFilters {
status?: string;
}
export function useUsers(filters: UserFilters) {
return useQuery<User[]>({
queryKey: ['users', filters],
queryFn: () => fetchUsers(filters),
staleTime: 1000 * 60 * 5,
});
}
export function useUser(userId: string | undefined) {
return useQuery<User>({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId!),
enabled: !!userId,
});
}
// Usage
function UsersList() {
const { data: users } = useUsers({ status: 'active' });
return <div>{/* Render users */}</div>;
}
Background Refetching
React Query automatically refetches stale data:
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: true, // Refetch when window regains focus
refetchOnReconnect: true, // Refetch when network reconnects
refetchInterval: 1000 * 60, // Poll every minute
},
},
});
Error Handling
Handle errors gracefully:
interface User {
id: string;
name: string;
}
interface UserProfileProps {
userId: string;
}
function UserProfile({ userId }: UserProfileProps) {
const { data, error, isError } = useQuery<User>({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
retry: 3, // Retry 3 times on failure
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
});
if (isError) {
return <ErrorBoundary error={error} />;
}
return <div>{/* Render user */}</div>;
}
Prefetching
Prefetch data before it's needed:
import { useQueryClient } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
interface UserLinkProps {
userId: string;
}
function UserLink({ userId }: UserLinkProps) {
const queryClient = useQueryClient();
const handleMouseEnter = () => {
queryClient.prefetchQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
};
return (
<Link
to={`/users/${userId}`}
onMouseEnter={handleMouseEnter}
>
User {userId}
</Link>
);
}
Real-World Example
Complete example from production:
// hooks/usePlayers.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
interface Player {
id: string;
name: string;
}
interface PlayerFilters {
search?: string;
position?: string;
}
interface PlayersResponse {
players: Player[];
}
export function usePlayers(filters: PlayerFilters) {
return useQuery<PlayersResponse>({
queryKey: ['players', filters],
queryFn: () => api.getPlayers(filters),
staleTime: 1000 * 60 * 5,
select: (data) => data.players, // Transform data
});
}
export function useCreatePlayer() {
const queryClient = useQueryClient();
return useMutation<Player, Error, Partial<Player>>({
mutationFn: api.createPlayer,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['players'] });
},
});
}
// Component
interface PlayersListProps {}
function PlayersList({}: PlayersListProps) {
const [filters, setFilters] = useState<PlayerFilters>({});
const { data: players, isLoading } = usePlayers(filters);
const createPlayer = useCreatePlayer();
if (isLoading) return <Loader />;
return (
<div>
<Filters filters={filters} onChange={setFilters} />
{players?.map(player => (
<PlayerCard key={player.id} player={player} />
))}
<button onClick={() => createPlayer.mutate({ name: 'New Player' })}>
Add Player
</button>
</div>
);
}
Best Practices
- Use query keys consistently: Follow a naming convention
- Create custom hooks: Encapsulate query logic
- Set appropriate stale times: Balance freshness vs. performance
- Handle errors gracefully: Provide good UX on failures
- Use select for transformations: Transform data in select, not in components
- Invalidate strategically: Only invalidate what needs refetching
- Leverage prefetching: Improve perceived performance
Performance Benefits
React Query provides:
- Automatic caching and deduplication
- Background refetching for fresh data
- Optimistic updates for instant UI feedback
- Request deduplication (same query runs once)
- Intelligent cache management
- Reduced boilerplate code
React Query transforms data fetching from a chore into a powerful, declarative experience. It's become essential for any React application that deals with server state.
Keep reading
React 19: What's New and Why It Matters
Explore the groundbreaking features in React 19, including Actions, useOptimistic, and the new compiler that transforms how we build React applications.
Performance Optimization in React: A Practical Guide
Learn practical techniques to optimize React applications, from code splitting to memoization strategies and rendering optimizations.
Handling Large Lists with Virtualization in React
Learn how to efficiently render thousands of list items using virtualization techniques, improving performance and user experience.