Learn how to add TanStack React Query to a React app: cache API responses, handle retries, and wire up create mutations. Part 3 of a 3-part React + API series.
React API Series: Part 3 – Data Fetching with React Query
In the final part of this series, we’ll add React Query (@tanstack/react-query) to handle caching, retries, and mutations. We’ll wire it into our existing app, replace manual loading/error state with React Query’s primitives, and add a create post form using useMutation.
Previously…
- Part 1 used the Fetch API to load /posts and covered loading and error states. See: /react-api-part-1-fetch
- Part 2 swapped in Axios and introduced a custom hook (usePosts) to encapsulate loading, error, and refetch. See: /react-api-part-2-axios-hooks
By the end of Part 3, you’ll have a production-friendly data layer with caching, retries, and easy mutations.
1) Install React Query and add the provider
Install the library:
npm i @tanstack/react-queryWrap your app with QueryClientProvider so components can use useQuery/useMutation.
Create or update src/main.jsx:
// src/main.jsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import App from './App.jsx';
// Configure a client with sensible defaults
const queryClient = new QueryClient({
defaultOptions: {
queries: {
// Cache stays "fresh" for 5 seconds; avoids refetch on quick remounts
staleTime: 5000,
// Retry transient failures a couple of times
retry: 2,
// Show fetching indicators but don’t block background refetches
refetchOnWindowFocus: true,
},
},
});
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</React.StrictMode>
);What this code does
- Creates a QueryClient with defaults for queries.
- Sets staleTime to 5s so cached data isn’t immediately refetched on remount.
- Wraps the app with QueryClientProvider so any component can call useQuery/useMutation.
- Leaves room to add devtools later without changing your components.
2) Keep the Axios-backed API client (getPosts and createPost)
We’ll continue using the Axios-powered API boundary from Part 2. If your file already exists, ensure it exposes both getPosts and createPost. Otherwise, create it now:
// src/api/client.js
import axios from 'axios';
const api = axios.create({
baseURL: 'https://jsonplaceholder.typicode.com',
});
export async function getPosts() {
const { data } = await api.get('/posts');
return data; // Array of posts
}
export async function createPost(post) {
// JSONPlaceholder fakes the write and returns a new object with id: 101
const { data } = await api.post('/posts', post);
return data;
}
export default api;What this code does
- Creates a reusable Axios instance pinned to the JSONPlaceholder base URL.
- Exposes getPosts() for reads and createPost() for writes.
- Keeps your transport concerns in one place so consumers don’t change when you swap Fetch/Axios.
Note: JSONPlaceholder doesn’t persist writes; we’ll still see realistic responses and can update our UI/cache locally.
3) Replace usePosts with useQuery in the UI
We’ll swap the custom hook usage for React Query’s useQuery directly, and add a small form to create posts with useMutation. We’ll also surface refetch and demonstrate caching by toggling the list.
Update src/App.jsx:
// src/App.jsx
import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getPosts, createPost } from './api/client';
function PostsList() {
const {
data: posts,
isLoading,
isError,
error,
refetch,
isFetching,
} = useQuery({
queryKey: ['posts'],
queryFn: getPosts,
// staleTime comes from QueryClient defaults (5s)
});
if (isLoading) return <div>Loading posts…</div>;
if (isError) return <div>Error: {error.message}</div>;
return (
<div>
<div style={{ marginBottom: 8 }}>
<button onClick={() => refetch()} disabled={isFetching}>
{isFetching ? 'Refreshing…' : 'Refetch Posts'}
</button>
</div>
<ul>
{posts.map((p) => (
<li key={p.id}>{p.title}</li>
))}
</ul>
<small>
Tip: Because staleTime is 5s, hiding and re-showing this list within 5s won’t refetch; it serves cached data.
</small>
</div>
);
}
function CreatePostForm() {
const queryClient = useQueryClient();
const [title, setTitle] = useState('');
const [body, setBody] = useState('');
const { mutate, isPending, isError, error } = useMutation({
mutationFn: createPost,
onSuccess: (newPost) => {
// Option A: Merge into cache for instant feedback
queryClient.setQueryData(['posts'], (old) => {
if (!old) return [newPost];
return [newPost, ...old];
});
// Option B: Or refetch server data
// queryClient.invalidateQueries({ queryKey: ['posts'] });
setTitle('');
setBody('');
},
});
function handleSubmit(e) {
e.preventDefault();
if (!title.trim() || !body.trim()) return;
mutate({ title, body, userId: 1 });
}
return (
<form onSubmit={handleSubmit} style={{ marginTop: 16 }}>
<h2>Create a Post</h2>
<div style={{ marginBottom: 8 }}>
<input
type="text"
placeholder="Title"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
/>
</div>
<div style={{ marginBottom: 8 }}>
<textarea
placeholder="Body"
value={body}
onChange={(e) => setBody(e.target.value)}
rows={3}
required
/>
</div>
<button type="submit" disabled={isPending}>
{isPending ? 'Creating…' : 'Create Post'}
</button>
{isError && (
<div style={{ color: 'crimson', marginTop: 8 }}>Error: {error.message}</div>
)}
</form>
);
}
export default function App() {
const [showList, setShowList] = useState(true);
return (
<div style={{ padding: 16 }}>
<h1>Posts</h1>
<button onClick={() => setShowList((s) => !s)} style={{ marginBottom: 12 }}>
{showList ? 'Hide' : 'Show'} List
</button>
{showList && <PostsList />}
<CreatePostForm />
</div>
);
}What this code does
- Replaces the custom usePosts hook with useQuery({ queryKey: ['posts'], queryFn: getPosts }).
- Surfaces loading/error states via isLoading/isError; provides refetch and a visual isFetching indicator.
- Adds a CreatePostForm that uses useMutation(createPost) and updates the cache with setQueryData on success.
- Shows how to invalidate queries (invalidateQueries) if you prefer a server refetch over local cache updates.
- Demonstrates caching with a Show/Hide toggle; thanks to staleTime, quick remounts serve cached data.
4) React Query patterns you just adopted
- Caching and staleness: Data returned by useQuery is cached by queryKey. With staleTime set (5s above), remounting or switching tabs within that window won’t trigger a network request. After it becomes stale, React Query refetches when appropriate (e.g., window refocus).
- Retries: Transient failures are retried (retry: 2). This improves resilience without extra code in your components.
- Mutations: useMutation standardizes writes. onSuccess gives you a single place to update cache or trigger invalidation.
- Refetch controls: refetch lets the user explicitly refresh data; isFetching indicates background network activity.
5) Recap of the 3‑part progression
- Part 1 (Fetch): You learned the basics—fetching /posts with fetch, handling loading and error states in a component. Link: /react-api-part-1-fetch
- Part 2 (Axios + hooks): You abstracted transport details into an Axios client and wrapped fetching logic in a custom hook. Link: /react-api-part-2-axios-hooks
- Part 3 (React Query): You introduced a full-featured data layer with caching, retries, and mutations, and simplified your UI logic.
Your existing files and responsibilities now look like this:
- src/api/client.js: API boundary using Axios; exports getPosts and createPost.
- src/hooks/usePosts.js: Legacy custom hook from Part 2. You can keep it as-is, or refactor it to call useQuery under the hood if you want a stable hook API.
- src/App.jsx: Minimal UI now powered by useQuery/useMutation; no manual isLoading/isError tracking required.
If you want to refactor your legacy hook to delegate to React Query, a minimal example might look like:
// Optional: src/hooks/usePosts.js (wrapper around React Query)
import { useQuery } from '@tanstack/react-query';
import { getPosts } from '../api/client';
export function usePosts() {
const query = useQuery({ queryKey: ['posts'], queryFn: getPosts });
return {
data: query.data,
loading: query.isLoading,
error: query.error,
reload: query.refetch,
};
}What this code does
- Exposes a backward-compatible shape (data/loading/error/reload).
- Delegates to useQuery so you get caching/retries without changing consumers.
Next steps
- Pagination and infinite queries: Use query keys like ['posts', { page }] and useInfiniteQuery for scrolling feeds.
- Error boundaries: Combine React Query’s error states with React error boundaries for robust UX.
- Optimistic updates: For mutations (create/update/delete), optimistically update the cache and roll back on error.
- Devtools: Add @tanstack/react-query-devtools to visualize cache and inspect stale status.
- Testing: Use MSW to mock network calls and test components that rely on React Query.
Series conclusion
You built an app that evolved from simple fetch calls to a resilient, cached data layer with mutations. You saw how isolating transport in src/api/client.js keeps components stable, and how React Query removes boilerplate while improving UX. Keep iterating: apply pagination, error boundaries, and tests to harden this foundation for production.