TanStack Query (formerly React Query) handles fetching, caching, synchronizing, and updating server state in React applications.

Setup

  npm install @tanstack/react-query
  
  // main.jsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

const queryClient = new QueryClient({
    defaultOptions: {
        queries: {
            staleTime: 60 * 1000, // 1 minute
            retry: 1
        }
    }
});

ReactDOM.createRoot(document.getElementById('root')).render(
    <QueryClientProvider client={queryClient}>
        <App />
    </QueryClientProvider>
);
  

Basic Query

  import { useQuery } from '@tanstack/react-query';

function UserProfile({ userId }) {
    const { data, isLoading, isError, error, refetch } = useQuery({
        queryKey: ['user', userId],
        queryFn: async () => {
            const res = await fetch(`/api/users/${userId}`);
            if (!res.ok) throw new Error('Failed to fetch user');
            return res.json();
        },
        enabled: !!userId // Only run when userId exists
    });

    if (isLoading) return <Spinner />;
    if (isError) return <p>Error: {error.message}</p>;

    return (
        <div>
            <h1>{data.name}</h1>
            <button onClick={() => refetch()}>Refresh</button>
        </div>
    );
}
  

Mutations

  import { useMutation, useQueryClient } from '@tanstack/react-query';

function CreateTodoForm() {
    const queryClient = useQueryClient();

    const mutation = useMutation({
        mutationFn: (newTodo) =>
            fetch('/api/todos', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify(newTodo)
            }).then(r => r.json()),
        onSuccess: () => {
            queryClient.invalidateQueries({ queryKey: ['todos'] });
        }
    });

    const handleSubmit = (e) => {
        e.preventDefault();
        mutation.mutate({ text: e.target.text.value });
    };

    return (
        <form onSubmit={handleSubmit}>
            <input name="text" disabled={mutation.isPending} />
            <button type="submit">
                {mutation.isPending ? 'Adding...' : 'Add'}
            </button>
            {mutation.isError && <p>{mutation.error.message}</p>}
        </form>
    );
}
  

Optimistic Updates

Update UI immediately, rollback on error:

  const mutation = useMutation({
    mutationFn: updateTodo,
    onMutate: async (updatedTodo) => {
        await queryClient.cancelQueries({ queryKey: ['todos'] });
        const previous = queryClient.getQueryData(['todos']);
        queryClient.setQueryData(['todos'], (old) =>
            old.map(t => t.id === updatedTodo.id ? updatedTodo : t)
        );
        return { previous };
    },
    onError: (err, updatedTodo, context) => {
        queryClient.setQueryData(['todos'], context.previous);
    },
    onSettled: () => {
        queryClient.invalidateQueries({ queryKey: ['todos'] });
    }
});
  

Pagination

  function PaginatedUsers() {
    const [page, setPage] = useState(1);

    const { data, isLoading } = useQuery({
        queryKey: ['users', page],
        queryFn: () => fetch(`/api/users?page=${page}`).then(r => r.json()),
        keepPreviousData: true
    });

    return (
        <div>
            {isLoading ? <Spinner /> : data.users.map(u => <UserCard key={u.id} user={u} />)}
            <button onClick={() => setPage(p => p - 1)} disabled={page === 1}>Prev</button>
            <button onClick={() => setPage(p => p + 1)} disabled={!data?.hasMore}>Next</button>
        </div>
    );
}
  

DevTools

  npm install @tanstack/react-query-devtools
  
  import { ReactQueryDevtools } from '@tanstack/react-query-devtools';

<QueryClientProvider client={queryClient}>
    <App />
    <ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
  

TanStack Query eliminates most manual loading/error state management for API data.