TanStack Query: How to Eliminate Manual Server State Management in ReactHow-To & Guides

How-To & Guides · 8 Jul 2026

TanStack Query: How to Eliminate Manual Server State Management in React

Learn how to use TanStack Query to manage cache, synchronization, and server states in React applications simply and efficiently.

Genildo Souza8 Jul 2026Read: 6 min

Learn how to use TanStack Query to manage data fetching, caching, updates, and synchronization in React applications, simplifying server state management.


What we will learn

  • - What TanStack Query is and why use it

  • - Basic QueryClient setup

  • - Data fetching with `useQuery`

  • - State management (loading, error, success)

  • - Data updates with `useMutation`

  • - Caching and query invalidation

  • - Advanced configurations (staleTime, cacheTime)

  • - Pagination and infinite loading

  • - Best practices and recommended patterns

Introduction to TanStack Query

Tired of manually managing loading states, handling network errors, and implementing complex caching logic in every component? TanStack Query, formerly known as React Query, eliminates this operational burden by automating data fetching, synchronization, and updates, allowing you to focus entirely on your React application logic.

Analogy: Imagine your React app is a restaurant. TanStack Query is like the kitchen manager who:

Analogia do TanStack Query como gerente de cozinha Usa Gerencia Verifica Solicita Aplicativo React O restaurante GERENTE TanStack Query Gerente de cozinha GERENTE Cache (pratos disponíveis) GERENTE staleTime (ingredientes frescos) GERENTE Refetch (solicita novos ingredientes)
TanStack Query analogy as a kitchen manager
  • Knows which dishes are available (cache)

  • Checks if ingredients are fresh (staleTime)

  • Requests new ingredients when necessary (refetch)

  • Handles special orders (mutations)

  • Notifies when something is sold out (error)

Fundamental Concept 1: Basic Setup

First, we need to set up the QueryClient and wrap our application:

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';

// Cria uma instância do QueryClient
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60 * 5, // 5 minutos
      refetchOnWindowFocus: false,
    },
  },
});

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

Explanation:

  • QueryClient manages cache and operations

  • QueryClientProvider provides the client to all components

  • defaultOptions defines global configurations

Tip: Keep a single instance of QueryClient for the entire application.

Fundamental Concept 2: Fetching Data with useQuery

The hook useQuery is used to fetch and synchronize data:

jsx
// src/components/UserList.jsx
import { useQuery } from '@tanstack/react-query';

const fetchUsers = async () => {
  const response = await fetch('https://api.example.com/users');
  if (!response.ok) {
    throw new Error('Falha ao buscar usuários');
  }
  return response.json();
};

export const UserList = () => {
  const { data, error, isLoading, isError } = useQuery({
    queryKey: ['users'], // Chave única para identificar esta query
    queryFn: fetchUsers, // Função que busca os dados
  });

  if (isLoading) {
    return <div>Carregando...</div>;
  }

  if (isError) {
    return <div>Erro: {error.message}</div>;
  }

  return (
    <div>
      <h1>Usuários</h1>
      <ul>
        {data.map(user => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    </div>
  );
};

Explanation:

  • queryKey: Unique identifier for this query (organizes the cache)

  • queryFn: Asynchronous function that returns data or throws an error

  • isLoading: Indicates if the query is loading for the first time

  • isError: Indicates if an error occurred

  • error: Error object if isError is true

  • data: The data returned by the query

Expected result:

  • Initially displays "Loading..."

  • After fetching, displays a list of users or an error message

Fundamental Concept 3: Updating Data with useMutation

useMutation is used to create, update, or delete data:

jsx
// src/components/AddUser.jsx
import { useMutation, useQueryClient } from '@tanstack/react-query';

const addUser = async (newUser) => {
  const response = await fetch('https://api.example.com/users', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(newUser),
  });
  if (!response.ok) {
    throw new Error('Falha ao adicionar usuário');
  }
  return response.json();
};

export const AddUser = () => {
  const queryClient = useQueryClient();
  const [name, setName] = useState('');

  const mutation = useMutation({
    mutationFn: addUser,
    onSuccess: () => {
      // Invalida a query de usuários para forçar refetch
      queryClient.invalidateQueries({ queryKey: ['users'] });
    },
  });

  const handleSubmit = (e) => {
    e.preventDefault();
    mutation.mutate({ name });
    setName('');
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        value={name}
        onChange={(e) => setName(e.target.value)}
        placeholder="Nome do usuário"
      />
      <button type="submit" disabled={mutation.isLoading}>
        {mutation.isLoading ? 'Adicionando...' : 'Adicionar Usuário'}
      </button>
      {mutation.isError && <div>Erro: {mutation.error.message}</div>}
    </form>
  );
};

Explanation:

  • useQueryClient: Accesses the client to invalidate queries

  • mutationFn: Function that executes the mutation

  • onSuccess: Callback executed when the mutation is successful

  • mutate: Function to trigger the mutation

  • isLoading: Indicates if the mutation is in progress

Advanced Concept: Query Configurations

TanStack Query offers many configurations to control behavior:

jsx
const { data } = useQuery({
  queryKey: ['posts'],
  queryFn: fetchPosts,
  // Dados são considerados frescos por 5 minutos
  staleTime: 1000 * 60 * 5,
  // Dados são mantidos no cache por 10 minutos após ficarem obsoletos
  cacheTime: 1000 * 60 * 10,
  // Refetch quando a janela ganhar foco
  refetchOnWindowFocus: true,
  // Refetch quando a conexão for restabelecida
  refetchOnReconnect: true,
  // Refetch quando a página for retomada
  refetchOnMount: true,
  // Busca em segundo plano (a cada 30 segundos)
  refetchInterval: 1000 * 30,
  // Desativa busca em segundo plano quando a aba estiver inativa
  refetchIntervalInBackground: false,
});

Practical Example 1: Pagination

jsx
// src/components/PaginatedPosts.jsx
import { useQuery } from '@tanstack/react-query';

const fetchPosts = async (page = 1, limit = 10) => {
  const response = await fetch(
    `https://api.example.com/posts?page=${page}&limit=${limit}`
  );
  if (!response.ok) {
    throw new Error('Falha ao buscar posts');
  }
  return response.json();
};

export const PaginatedPosts = () => {
  const [page, setPage] = useState(1);
  const { data, isLoading, isError } = useQuery({
    queryKey: ['posts', page], // Inclui a página na chave
    queryFn: () => fetchPosts(page),
    keepPreviousData: true, // Mantém dados da página anterior
  });

  if (isLoading) return <div>Carregando...</div>;
  if (isError) return <div>Erro ao carregar posts</div>;

  return (
    <div>
      <h1>Posts</h1>
      <ul>
        {data.posts.map(post => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
      <div>
        <button onClick={() => setPage(page - 1)} disabled={page === 1}>
          Anterior
        </button>
        <span> Página {page} </span>
        <button onClick={() => setPage(page + 1)}>
          Próximo
        </button>
      </div>
    </div>
  );
};

Practical Example 2: Infinite Loading

jsx
// src/components/InfinitePosts.jsx
import { useInfiniteQuery } from '@tanstack/react-query';

const fetchPosts = async ({ pageParam = 1 }) => {
  const response = await fetch(
    `https://api.example.com/posts?page=${pageParam}`
  );
  if (!response.ok) {
    throw new Error('Falha ao buscar posts');
  }
  return response.json();
};

export const InfinitePosts = () => {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    status,
  } = useInfiniteQuery({
    queryKey: ['infinite-posts'],
    queryFn: fetchPosts,
    getNextPageParam: (lastPage) => lastPage.nextPage,
    initialPageParam: 1,
  });

  if (status === 'loading') return <div>Carregando...</div>;
  if (status === 'error') return <div>Erro ao carregar posts</div>;

  return (
    <div>
      <h1>Posts Infinitos</h1>
      <div>
        {data.pages.map((page, i) => (
          <div key={i}>
            {page.posts.map(post => (
              <div key={post.id}>{post.title}</div>
            ))}
          </div>
        ))}
      </div>
      <button
        onClick={() => fetchNextPage()}
        disabled={!hasNextPage || isFetchingNextPage}
      >
        {isFetchingNextPage
          ? 'Carregando mais...'
          : hasNextPage
          ? 'Carregar Mais'
          : 'Nada mais para carregar'}
      </button>
    </div>
  );
};

Common Errors and How to Avoid Them

  1. Not invalidating queries after mutations

    • Problem: Displayed data does not reflect recent changes

    • Solution: Use onSuccess in useMutation to invalidate relevant queries

  2. Inconsistent query keys

    • Problem: The same endpoint with different parameters is treated as the same query

    • Solution: Include all relevant parameters in the queryKey

  3. Forgetting to handle error states

    • Problem: The application crashes when a network failure occurs

    • Solution: Always check isError and error in your components

  4. Using staleTime too short

    • Problem: Too many unnecessary requests to the server

    • Solution: Adjust the staleTime based on data update frequency

Common Error: Not Invalidating Queries After Mutations

When you perform a mutation, the displayed data may become stale if related queries are not invalidated.

This causes UI inconsistency, showing information that does not reflect recent changes.

The solution is to use the onSuccess callback of useMutation to invalidate relevant queries, ensuring data is updated automatically.

Best Practices

  • Use specific and predictable query keys

  • Keep fetch functions pure and without side effects

  • Handle all possible states (loading, error, success)

  • Configure staleTime and cacheTime appropriately for your use case

  • Use select to transform data in the component when necessary

  • Prefer invalidating queries over manual refetching

  • Document your query keys to facilitate maintenance

Going further

Takeaway: TanStack Query drastically simplifies server state management in React. By understanding the fundamental concepts of queries, mutations, and caching, you can build more responsive, resilient, and maintainable applications. Start with basic configurations and gradually explore advanced features as your needs grow.