React Query

For years, React developers treated data fetched from an API as “global state,” storing it in Redux alongside UI state like “isModalOpen.” This was a mistake.

Server State is different from Client State:

  • It is persisted remotely in a location you do not control.
  • It requires asynchronous APIs for fetching and updating.
  • It implies shared ownership and can be changed by other people without your knowledge.
  • It can become “stale” if you’re not careful.

React Query (now TanStack Query) solves these problems by managing the lifecycle of your data fetching.

1. The Lifecycle of a Query

React Query manages queries through a specific lifecycle. Understanding these states is key to mastering the library.

Interactive: Query Lifecycle Simulator

Click the buttons to simulate fetching data and see how the state transitions.

IDLE
Stale Time: 5s
DEVTOOLS
Status: idle
Data: null
IsFetching: false
IsStale: false
Event Log:
> Waiting for action...

2. Key Concepts

1. Stale-While-Revalidate

This is the magic of React Query. It allows your app to show “stale” (old) data instantly from the cache while fetching “fresh” data in the background. Once the fresh data arrives, the UI updates silently. No loading spinners for subsequent visits!

2. Caching

Every query is identified by a Query Key (e.g., ['todos', 1]). React Query handles deduplication automatically. If 5 components request the same key simultaneously, only one network request is sent.

3. Background Refetching

React Query assumes data on the server changes constantly. It automatically refetches when:

  • The window is refocused.
  • The network reconnects.
  • The component mounts.
  • The staleTime has expired.

3. Basic Usage

The useQuery hook is your primary tool.

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

interface Todo {
  id: number;
  title: string;
}

function TodoList() {
  const { isPending, error, data } = useQuery({
    queryKey: ['todos'],
    queryFn: async (): Promise<Todo[]> => {
      const response = await fetch('https://api.example.com/todos');
      if (!response.ok) {
        throw new Error('Network response was not ok');
      }
      return response.json();
    },
    staleTime: 1000 * 60 * 5, // Data is fresh for 5 minutes
  });

  if (isPending) return <div>Loading...</div>;

  if (error) return <div>An error has occurred: {error.message}</div>;

  return (
    <ul>
      {data.map((todo) => (
        <li key={todo.id}>{todo.title}</li>
      ))}
    </ul>
  );
}

[!WARNING] Don’t ignore keys! The queryKey dependency array works like useEffect. If your fetch function depends on a variable (like a filter or ID), that variable must be in the query key: ['todos', filter].

4. Mutations (Changing Data)

Fetching is easy; updating is hard. useMutation handles side effects (POST/PUT/DELETE).

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

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

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

  return (
    <button
      onClick={() => mutation.mutate({ title: 'Do Laundry' })}
      disabled={mutation.isPending}
    >
      {mutation.isPending ? 'Adding...' : 'Add Todo'}
    </button>
  );
}

5. Optimistic Updates

For a “snappy” feel, you can update the UI before the server responds.

User Click mutate() Update UI (Optimistic) onMutate Network Request Server Success onSuccess Refetch List invalidateQueries

This makes your app feel instant, even on slow networks. If the server fails, React Query rolls back the UI automatically.