React Query Beginner Guide
By Braincuber Team
Published on January 21, 2026
Fetching data in React without proper tooling means juggling useState, useEffect, loading states, error handling, and manual cache management. Every API call becomes 30+ lines of boilerplate. React Query (TanStack Query) eliminates this pain—automatic caching, background refetching, optimistic updates, and built-in devtools. It's the difference between duct-taping state management and using a production-grade solution.
This tutorial covers React Query fundamentals: setup, query hooks, mutations, caching strategies, and real-world patterns. You'll build a task management app that demonstrates CRUD operations, pagination, and automatic UI synchronization.
What You'll Learn: Installing and configuring React Query. Fetching data with useQuery. Creating/updating data with useMutation. Implementing pagination and infinite scroll. Debugging with React Query Devtools.
The Problem: Manual Data Fetching
Traditional approach with useEffect + useState:
const [tasks, setTasks] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetch('https://api.example.com/tasks')
.then(res => res.json())
.then(data => {
setTasks(data);
setLoading(false);
})
.catch(err => {
setError(err.message);
setLoading(false);
});
}, []);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
Issues: No caching (refetches on every mount). No background updates. No retry logic. Manual loading/error states. Duplication across components. No request deduplication.
The Solution: React Query
| Feature | useEffect + useState | React Query |
|---|---|---|
| Caching | Manual implementation | Automatic with configurable TTL |
| Loading States | Manual useState | Built-in isLoading |
| Error Handling | Manual try/catch | Built-in error object |
| Refetch on Focus | Custom logic required | Automatic (configurable) |
| Retry Logic | Manual implementation | 3 retries by default |
| Deduplication | None | Automatic per query key |
Setup Guide
Install React Query
npm install @tanstack/react-query @tanstack/react-query-devtools
Configure QueryClient
Wrap your app with QueryClientProvider.
import ReactDOM from 'react-dom/client';
import App from './App';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
retry: 2,
refetchOnWindowFocus: true,
},
},
});
ReactDOM.createRoot(document.getElementById('root')).render(
<QueryClientProvider client={queryClient}>
<App />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
Configuration: staleTime: How long data is considered fresh. retry: Automatic retry attempts on failure. refetchOnWindowFocus: Refetch when user returns to tab.
Fetch Data with useQuery
Replace useEffect + useState with useQuery.
import { useQuery } from '@tanstack/react-query';
const fetchTasks = async () => {
const res = await fetch('https://jsonplaceholder.typicode.com/todos?_limit=10');
if (!res.ok) throw new Error('Failed to fetch tasks');
return res.json();
};
function TaskList() {
const { data, isLoading, error, refetch } = useQuery({
queryKey: ['tasks'],
queryFn: fetchTasks,
});
if (isLoading) return <div>Loading tasks...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<div>
<h2>Task List</h2>
<button onClick={() => refetch()}>Refresh</button>
<ul>
{data.map(task => (
<li key={task.id}>
{task.completed ? '✅' : '⬜'} {task.title}
</li>
))}
</ul>
</div>
);
}
export default TaskList;
Create Data with useMutation
Handle POST/PUT/DELETE requests.
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
const addTask = async (newTask) => {
const res = await fetch('https://jsonplaceholder.typicode.com/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newTask),
});
if (!res.ok) throw new Error('Failed to create task');
return res.json();
};
function AddTask() {
const [title, setTitle] = useState('');
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: addTask,
onSuccess: () => {
// Invalidate and refetch tasks query
queryClient.invalidateQueries({ queryKey: ['tasks'] });
setTitle('');
},
});
const handleSubmit = (e) => {
e.preventDefault();
mutation.mutate({ title, completed: false });
};
return (
<form onSubmit={handleSubmit}>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="New task..."
/>
<button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Adding...' : 'Add Task'}
</button>
{mutation.isError && <div>Error: {mutation.error.message}</div>}
</form>
);
}
export default AddTask;
Advanced Features
Automatic Refetching
useQuery({
queryKey: ['tasks'],
queryFn: fetchTasks,
refetchInterval: 30000, // 30s
});
Pagination
const [page, setPage] = useState(1);
useQuery({
queryKey: ['tasks', page],
queryFn: () => fetchPage(page),
keepPreviousData: true,
});
Optimistic Updates
onMutate: async (newTask) => {
await queryClient.cancelQueries(['tasks']);
const prev = queryClient.getQueryData(['tasks']);
queryClient.setQueryData(['tasks'], old => [...old, newTask]);
return { prev };
}
Conclusion
React Query transforms data fetching from a chore into a superpower. Automatic caching eliminates redundant requests. Background refetching keeps data fresh. Mutations with invalidation sync UI instantly. Devtools provide visibility into every query state. For any app fetching remote data, React Query is essential.
Next Steps: Implement infinite scroll with useInfiniteQuery. Add optimistic updates. Configure retry strategies. Explore query prefetching. Use dependent queries for sequential data loading.
