React + APIs, Part 3: Caching, retries, and mutations with React Query

John John 8 min
React + APIs, Part 3: Caching, retries, and mutations with React Query

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-query

Wrap 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.