Disponível para freelancerEntre em contato para que eu possa ajudar seu negócio a crescer ou tirar sua ideia do papel!

Estou interessado
Usando o TanStack Query para Substituir Gerenciamento de Estado Complexo

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 + useState em vez de useQuery. 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 isLoading como "ainda não tem dado". Use isPending e isFetching corretamente — isFetching também fica true durante 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 staleTime de forma deliberada. O padrão de 0 faz 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.ts exportando um hook useProjectsQuery(userId) é bem mais fácil de manter do que useQuery espalhado direto pelos componentes.
  • Use select para moldar o dado, em vez de mais um useEffect. 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.prefetchQuery deixa 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.