
Usando o TanStack Query para Substituir Gerenciamento de Estado Complexo
Já vi a mesma slice de Redux em três codebases diferentes: isLoading, error, data, e um action creator para cada transição possível entre eles. O fetch começa, dispara FETCH_USERS_REQUEST. O fetch dá certo, dispara FETCH_USERS_SUCCESS. O fetch falha, dispara FETCH_USERS_FAILURE. Multiplica isso por cada endpoint da sua aplicação e você tem uma camada de gerenciamento de estado que existe só para transportar respostas do servidor até a store.
Isso não é gerenciamento de estado. É boilerplate se fazendo passar por arquitetura.
O problema real: dois tipos de estado, uma única ferramenta
A maioria das aplicações mistura duas categorias de estado completamente diferentes e trata as duas do mesmo jeito:
- Estado do cliente — toggles de UI, campos de formulário, visibilidade de modais, preferência de tema. Isso vive só no navegador e só você controla quando muda.
- Estado do servidor — dados que vivem em um servidor, que você não controla, e que podem ficar desatualizados no exato momento em que outro usuário ou outra aba mexe neles.
Redux (e Zustand, e Context + useReducer) foram construídos para estado do cliente. São síncronos, previsíveis, e te dão controle total sobre as transições. O estado do servidor quebra essas três premissas:
- É assíncrono por natureza.
- Pode estar desatualizado antes mesmo do seu componente renderizar.
- Vários componentes costumam querer os mesmos dados, e você não quer cinco fetches separados para isso.
Quando você força o estado do servidor dentro de uma ferramenta feita para estado do cliente, você acaba reimplementando cache, deduplicação, retries e refetch em background na mão — e mal, porque esse nunca foi o problema que a ferramenta foi feita para resolver.
O que o TanStack Query realmente faz
O TanStack Query (antigo React Query) não é um gerenciador de estado no sentido do Redux. É uma camada de sincronização de dados. Você entrega uma chave e uma função que busca os dados, e ele cuida de:
- Cache por chave, então requisições idênticas entre componentes compartilham um único fetch
- Deduplicação de requisições em andamento
- Refetch em background quando a janela ganha foco ou a conexão volta
- Retries com backoff exponencial
- Estados de loading e erro, derivados automaticamente — você nunca dispara esses estados manualmente
A mudança de mentalidade é o que importa aqui: você para de perguntar "como eu atualizo a store quando essa requisição terminar?" e passa a perguntar "quais dados esse componente precisa, e quão atualizados eles precisam estar?"
Configurando o projeto
Instale a lib e envolva sua aplicação com um provider, uma única vez:
npm install @tanstack/react-query
// app/providers.tsx
'use client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useState } from 'react'
export function AppProviders({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // dado é considerado "fresco" por 1 minuto
retry: 1,
},
},
})
)
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
}
É toda a configuração necessária. Sem slices, sem middleware, sem combineReducers.
Substituindo um fluxo de fetch do Redux
Aqui está uma configuração típica de Redux para carregar os projetos de um usuário — o tipo de código que eu já deletei mais vezes do que consigo contar:
// ❌ Redux: três action types, um reducer e um thunk só pra buscar uma lista
const projectsSlice = createSlice({
name: 'projects',
initialState: { data: [], loading: false, error: null },
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchProjects.pending, (state) => {
state.loading = true
state.error = null
})
.addCase(fetchProjects.fulfilled, (state, action) => {
state.loading = false
state.data = action.payload
})
.addCase(fetchProjects.rejected, (state, action) => {
state.loading = false
state.error = action.error.message
})
},
})
export const fetchProjects = createAsyncThunk('projects/fetch', async (userId: string) => {
const res = await fetch(`/api/users/${userId}/projects`)
if (!res.ok) throw new Error('Failed to fetch projects')
return res.json()
})
E o componente ainda precisa disparar a action, selecionar o estado e lidar com o timing:
// ❌ Componente ainda gerencia o ciclo de vida do fetch manualmente
function ProjectsList({ userId }: { userId: string }) {
const dispatch = useDispatch()
const { data, loading, error } = useSelector((state: RootState) => state.projects)
useEffect(() => {
dispatch(fetchProjects(userId))
}, [dispatch, userId])
if (loading) return <Spinner />
if (error) return <ErrorMessage message={error} />
return (
<ul>
{data.map((p) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
)
}
Agora a mesma coisa com TanStack Query:
// ✅ TanStack Query: fetch, cache e estados em um único hook
import { useQuery } from '@tanstack/react-query'
function ProjectsList({ userId }: { userId: string }) {
const { data, isLoading, error } = useQuery({
queryKey: ['projects', userId],
queryFn: async () => {
const res = await fetch(`/api/users/${userId}/projects`)
if (!res.ok) throw new Error('Failed to fetch projects')
return res.json()
},
})
if (isLoading) return <Spinner />
if (error) return <ErrorMessage message={error.message} />
return (
<ul>
{data.map((p) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
)
}
Sem slice. Sem thunk. Sem dispatch. Se outro componente na mesma página também chamar useQuery(['projects', userId]), ele reaproveita o mesmo cache em vez de disparar uma segunda requisição.
Mutations funcionam do mesmo jeito
Atualizar dados segue o mesmo padrão, e você ganha invalidação de cache de graça:
import { useMutation, useQueryClient } from '@tanstack/react-query'
function useCreateProject(userId: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (name: string) => {
const res = await fetch(`/api/users/${userId}/projects`, {
method: 'POST',
body: JSON.stringify({ name }),
})
return res.json()
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['projects', userId] })
},
})
}
invalidateQueries avisa ao TanStack Query: "esse dado está desatualizado, busque de novo na próxima vez que for usado." Essa única linha substitui uma classe inteira de bugs do tipo "esqueci de atualizar a store depois dessa mutation".
Erros comuns
- ❌ Colocar dados de servidor no Redux "só por garantia". Se o valor vem de uma API, ele não pertence à sua store global de cliente. Deixa o cache do query cuidar disso.
- ❌ Usar a combinação
useEffect+useStateem vez deuseQuery. Esse é exatamente o padrão que o TanStack Query existe para substituir — você perde cache, retries e deduplicação à toa. - ❌ Esquecer a unicidade da
queryKey.['projects']e['projects', userId]são caches diferentes. Se sua chave não incluir toda variável da qual o query depende, você vai acabar servindo o dado de um usuário para outro. - ❌ Tratar
isLoadingcomo "ainda não tem dado". UseisPendingeisFetchingcorretamente —isFetchingtambém ficatruedurante refetches em background, e mostrar um spinner de página inteira nesses casos cria uma experiência ruim.
Boas práticas
- Mantenha estado de cliente e estado de servidor em ferramentas separadas. Zustand ou Context para estado de UI, TanStack Query para qualquer coisa que venha de um servidor.
- Defina o
staleTimede forma deliberada. O padrão de0faz refetch de forma agressiva. Para dados que não mudam com frequência (perfil do usuário, configurações), alguns minutos de "desatualização" costumam estar de bom tamanho e cortam bastante tráfego de rede. - Coloque as query keys perto das queries que as usam. Um arquivo
queries/projects.tsexportando um hookuseProjectsQuery(userId)é bem mais fácil de manter do queuseQueryespalhado direto pelos componentes. - Use
selectpara moldar o dado, em vez de mais umuseEffect. Se você precisa de um valor derivado, transforme dentro da própria query:
useQuery({
queryKey: ['projects', userId],
queryFn: fetchProjects,
select: (data) => data.filter((p) => !p.archived),
})
- Faça prefetch no hover ou na transição de rota para páginas que você sabe que o usuário está prestes a visitar.
queryClient.prefetchQuerydeixa a navegação parecendo instantânea sem precisar de gerenciamento de estado extra.
O que fazer agora
Se você mantém uma store de Redux hoje, não reescreva tudo de uma vez. Escolha uma slice que seja puramente dado de servidor — aquela com mais boilerplate de pending/fulfilled/rejected — e substitua por uma única chamada useQuery. Apague o reducer, o thunk e os selectors. Meça quanto código sumiu.
Faça isso para cada slice de "buscar e guardar" na sua aplicação, e é bem provável que sua store de Redux encolha até sobrar só o que ela sempre deveria ter guardado: estado real de cliente. Esse é um bom sinal de que sua arquitetura finalmente bate com o formato dos seus dados.