TanStack for Beginners
TanStack is a powerful collection of headless utilities for building modern web applications that has transformed how developers handle data fetching, state management, and UI interactions in React.
TanStack includes all the essential features you'd expect in modern state management solutions: smart caching, background refetching, optimistic updates, and smooth error handling. You can use it with different frameworks, but it works especially well with React where it cuts down on boilerplate code and makes your apps feel faster.
This guide will show you how to build a complete data management solution for your React app using TanStack Query. You'll learn how to use its features and configure it for the best performance in your specific situation.
Prerequisites
Before you begin this guide, ensure that you have a recent version of Node.js and npm
installed on your computer. This tutorial assumes you know the basics of React, including hooks, component lifecycle, and basic state management.
Getting started with TanStack Query
To get the most out of this tutorial, you'll create a fresh React project to try out everything covered in this guide. Start by setting up a new React application using Vite:
npm create vite@latest tanstack-demo -- --template react
cd tanstack-demo
npm install
npm run dev
Your browser should automatically open to http://localhost:5173
and show the default Vite React welcome page:
Keep this development server running throughout the tutorial because you'll be making changes frequently and watching the results in real-time.
Next, install the core TanStack Query package and its development tools:
npm install @tanstack/react-query @tanstack/react-query-devtools
Create a new queryClient.js
file in your src
directory and add this configuration:
import { QueryClient } from '@tanstack/react-query';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
cacheTime: 1000 * 60 * 10, // 10 minutes
},
},
});
This setup creates a query client with good default settings for data freshness and cache duration. You'll explore different ways to customize the query client throughout this tutorial, but these defaults work well for most apps.
Now update your main App.js
file to use the query client:
import React from 'react';
import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { queryClient } from './queryClient';
import UserList from './UserList';
function App() {
return (
<QueryClientProvider client={queryClient}>
<div className="App">
<h1>TanStack Query Demo</h1>
<UserList />
</div>
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
export default App;
Create a simple component to see TanStack Query in action. Make a UserList.jsx
file:
import React from 'react';
import { useQuery } from '@tanstack/react-query';
const fetchUsers = async () => {
const response = await fetch('https://jsonplaceholder.typicode.com/users');
if (!response.ok) {
throw new Error('Failed to fetch users');
}
return response.json();
};
const UserList = () => {
const { data: users, isLoading, error } = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
});
if (isLoading) return <div>Loading users...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<div>
<h2>Users</h2>
<ul>
{users.map(user => (
<li key={user.id}>{user.name} - {user.email}</li>
))}
</ul>
</div>
);
};
export default UserList;
Save your files and go back to your browser. You should now see a list of users loaded from the JSONPlaceholder API:
Notice the floating React Query DevTools icon in the bottom-right corner of your browser window - click it to open the powerful debugging interface that shows query states, cache contents, and network activity.
Now that you've set up TanStack Query successfully and seen its basic functionality, let's explore how queries work internally, their different states, and the powerful caching features that make this library so effective.
Understanding query states and caching
TanStack Query uses several distinct states to control how your data gets fetched and displayed. The main states are loading
, error
, success
, and idle
. Each one represents a different phase in the data lifecycle.
When a query first runs, it enters the loading
state while the network request happens. When it finishes successfully, the query moves to success
state, and the returned data becomes available to your component. If an error occurs during fetching, the query moves to the error
state and gives you detailed error information.
The caching system is where TanStack Query really shines. Once you fetch data, it gets stored in a smart cache that can serve future requests instantly. The staleTime
setting determines how long cached data stays "fresh" before becoming stale, while cacheTime
controls how long unused data stays in memory.
Let's improve our example to show these concepts. Update your UserList.jsx
file:
import React from 'react';
import { useQuery } from '@tanstack/react-query';
const fetchUsers = async () => {
console.log('Fetching users from API...');
const response = await fetch('https://jsonplaceholder.typicode.com/users');
if (!response.ok) {
throw new Error('Failed to fetch users');
}
return response.json();
};
const UserList = () => {
const {
data: users,
isLoading,
error,
isStale,
isFetching,
dataUpdatedAt
} = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
staleTime: 30000, // 30 seconds
cacheTime: 300000, // 5 minutes
});
if (isLoading) return <div>Loading users...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<div>
<h2>Users</h2>
<div style={{ backgroundColor: '#f0f0f0', padding: '10px', marginBottom: '20px' }}>
<p>Data is {isStale ? 'stale' : 'fresh'}</p>
<p>Currently fetching: {isFetching ? 'Yes' : 'No'}</p>
<p>Last updated: {new Date(dataUpdatedAt).toLocaleTimeString()}</p>
</div>
<ul>
{users.map(user => (
<li key={user.id}>{user.name} - {user.email}</li>
))}
</ul>
</div>
);
};
export default UserList;
Save your changes and refresh your browser. You should see the enhanced user list with query state information:
Watch how the console message "Fetching users from API..." appears only once initially. Switch to another browser tab for more than 30 seconds, then come back - you'll see the data becomes stale and a background refetch happens. Open your browser's Developer Tools (F12) to see these console messages and network requests in action.
The query state indicators help you understand when data is considered stale and when background refetching occurs. You can also see this information in the React Query DevTools panel.
Understanding these query states and caching behaviors is crucial for building efficient apps. With this foundation ready, let's explore how to handle data changes through mutations and implement optimistic updates for an even better user experience.
Mutations and optimistic updates
While queries handle data fetching, mutations manage data changes like creating, updating, or deleting records. TanStack Query's mutation system gives you powerful features including optimistic updates, automatic retry logic, and smooth error handling.
Let's create a component that shows how to add new users. Create a new AddUser.jsx
file:
import React, { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
const createUser = async (userData) => {
const response = await fetch('https://jsonplaceholder.typicode.com/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(userData),
});
if (!response.ok) {
throw new Error('Failed to create user');
}
return response.json();
};
const AddUser = () => {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: createUser,
onSuccess: (newUser) => {
// Invalidate and refetch users query
queryClient.invalidateQueries({ queryKey: ['users'] });
// Reset form
setName('');
setEmail('');
},
onError: (error) => {
console.error('Error creating user:', error);
},
});
const handleSubmit = (e) => {
e.preventDefault();
mutation.mutate({ name, email });
};
return (
<div style={{ backgroundColor: '#f9f9f9', padding: '20px', marginBottom: '20px' }}>
<h3>Add New User</h3>
<form onSubmit={handleSubmit}>
<div style={{ marginBottom: '10px' }}>
<input
type="text"
placeholder="Name"
value={name}
onChange={(e) => setName(e.target.value)}
style={{ marginRight: '10px', padding: '5px' }}
required
/>
<input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
style={{ marginRight: '10px', padding: '5px' }}
required
/>
<button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Adding...' : 'Add User'}
</button>
</div>
</form>
{mutation.error && (
<div style={{ color: 'red' }}>
Error: {mutation.error.message}
</div>
)}
</div>
);
};
export default AddUser;
Here's what's happening in the code.
The createUser
function makes a POST request to the JSONPlaceholder API to simulate creating a new user. It sends the user data as JSON and throws an error if the request fails.
The AddUser
component creates a form with two input fields for name and email. It uses React's useState
to manage the form state and useQueryClient
to access TanStack Query's cache management.
The mutation setup uses useMutation
to handle the user creation process. When the mutation succeeds (onSuccess
), it automatically invalidates the users query cache, forcing a refetch of the user list to show the updated data. It also resets the form fields. If an error occurs (onError
), it logs the error to the console.
The form handling prevents the default form submission and calls mutation.mutate()
with the current name and email values. The submit button shows "Adding..." while the mutation is in progress (mutation.isPending
) and gets disabled to prevent multiple submissions.
The UI renders a simple form with styling, shows any error messages that occur during the mutation, and provides visual feedback about the loading state.
This creates a complete user creation flow that automatically keeps your UI synchronized with the server data through TanStack Query's cache invalidation system.
Update your App.js
to include the new component:
import React from 'react';
import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { queryClient } from './queryClient';
import UserList from './UserList';
import AddUser from './AddUser';
function App() {
return (
<QueryClientProvider client={queryClient}>
<div className="App" style={{ padding: '20px' }}>
<h1>TanStack Query Demo</h1>
<AddUser />
<UserList />
</div>
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
export default App;
In your browser, you'll now see a form above the user list. Try adding a new user and watch how the form resets:
Note that with JSONPlaceholder (our demo API), new users won't actually appear in the list because it's a mock API that doesn't save data permanently. However, you can still observe how TanStack Query handles the mutation lifecycle through the DevTools.
Open the React Query DevTools and watch the mutation process. When you fill out the form with a name and email and click "Add User", you'll see a new mutation appear in the Mutations tab with a "pending" status. At the same time, look at the Queries tab where you'll notice the ['users']
query gets "invalidated" and changes from "fresh" to "stale" to "fetching" as it automatically refetches the data.
Here's what the process looks like. Before submitting the form:
After completion: The mutation disappears, the users query shows as "fresh" again, and the form gets reset with cleared input fields:
The key insight here is watching how TanStack Query automatically coordinates between your mutation and your queries. When you add a user, it knows to refetch the users query to keep everything synchronized without you having to trigger the refresh manually.
Making your app feel faster with optimistic updates
By default, mutations only update your UI after the server confirms the change. But you can make your interface feel much faster and more responsive with optimistic updates. This technique assumes the mutation will succeed and immediately shows the change in the UI, while quietly syncing with the server in the background. If the server responds with an error, you can roll back to the previous state.
Let's modify the AddUser
component to include optimistic updates:
const mutation = useMutation({
mutationFn: createUser,
onMutate: async (newUser) => {
// Cancel any outgoing refetches
await queryClient.cancelQueries({ queryKey: ['users'] });
// Snapshot the previous value
const previousUsers = queryClient.getQueryData(['users']);
// Optimistically update to the new value
queryClient.setQueryData(['users'], (old) => [
...old,
{
...newUser,
id: Date.now(), // Temporary ID
phone: '1-555-000-0000',
website: 'example.com'
}
]);
// Return a context object with the snapshotted value
return { previousUsers };
},
onError: (err, newUser, context) => {
// If the mutation fails, use the context to roll back
queryClient.setQueryData(['users'], context.previousUsers);
},
onSettled: () => {
// For demo purposes, we skip refetching since JSONPlaceholder doesn't persist data
// In a real app, you would refetch here:
// queryClient.invalidateQueries({ queryKey: ['users'] });
},
onSuccess: () => {
// Reset form
setName('');
setEmail('');
},
});
Now when you submit the form, you'll see the new user appear instantly at the bottom of the user list, creating a much more responsive experience:
The optimistic update process works in three stages:
- onMutate - Runs before the mutation starts and immediately updates the UI
- onError - Rolls back changes if the mutation fails
- onSettled - Runs after success or failure to ensure data consistency
You can watch this entire process in the React Query DevTools, where each stage is tracked and displayed in real-time.
Understanding mutations and optimistic updates gives you the tools to create highly interactive apps. Next, you'll explore advanced query techniques, including dependent queries that let you chain data fetching based on user interactions.
Final thoughts
Throughout this guide, you've explored TanStack Query's fundamental concepts and advanced patterns, from basic queries to sophisticated mutations with optimistic updates. You now have the knowledge to implement robust, efficient data fetching solutions in your React applications.
TanStack Query offers much more beyond what you've covered here, including infinite queries, suspense integration, and SSR support. Dive deeper into the official TanStack documentation to explore these advanced features and discover how they can benefit your specific use cases.
Consider exploring other TanStack utilities like TanStack Router and TanStack Virtual to create a comprehensive development toolkit. Remember to leverage the React Query DevTools for debugging and optimization as you build more complex applications.
Make your mark
Join the writer's program
Are you a developer and love writing and sharing your knowledge with the world? Join our guest writing program and get paid for writing amazing technical guides. We'll get them to the right readers that will appreciate them.
Write for us
Build on top of Better Stack
Write a script, app or project on top of Better Stack and share it with the world. Make a public repository and share it with us at our email.
community@betterstack.comor submit a pull request and help us build better products for everyone.
See the full list of amazing projects on github