Projeto final: app de tarefas com fetch, mutations, cache e offline
5 min de leitura
Esse é o projeto que junta tudo que vimos nos 6 nós
anteriores. A ideia é construir um app de tarefas (CRUD
completo) usando TanStack Query como o único mecanismo
de leitura e escrita de dados - nada de useState + fetch
pra dados do servidor. Quando você terminar, vai ter
praticado:
useQuerypra listar tarefasuseMutationpra criar, editar, deletar- Optimistic update em "marcar como feita"
- Cache invalidation depois de cada mutation
useMutationStatepra indicador global de "salvando"- React Query Devtools pra debugar
- Offline behavior com
networkMode
O que você vai construir
Um app de página única (SPA) com:
- Lista de tarefas (carrega do servidor via
useQuery) - Criar tarefa (mutation POST)
- Editar tarefa (mutation PUT)
- Deletar tarefa (mutation DELETE)
- Marcar como feita (mutation PATCH com optimistic update)
- Filtro por status (pendente / feita / todas) - usa
useQuerycom queryKey derivada - Indicador "salvando..." no header quando qualquer
mutation está rodando (
useMutationState) - Comportamento offline: queries pausam sem rede, voltam a rodar quando reconecta
A UI é propositalmente simples: HTML sem estilização elaborada, foco no comportamento de dados. Você pode usar o framework de UI que quiser (ou nenhum) - o que importa é o TanStack Query.
Setup
-
Crie um projeto React novo com Vite + TypeScript:
npm create vite@latest tarefas-tanstack -- --template react-ts cd tarefas-tanstack npm install -
Instale as dependências:
npm install @tanstack/react-query @tanstack/react-query-devtools -
Configure o
QueryClientnomain.tsx:import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 30 * 1000, // 30s retry: 1, // tenta 1x em caso de erro }, }, }); ReactDOM.createRoot(document.getElementById("root")!).render( <QueryClientProvider client={queryClient}> <App /> {import.meta.env.DEV && <ReactQueryDevtools initialIsOpen={false} />} </QueryClientProvider>, ); -
Suba uma API local. Você tem três opções, em ordem de simplicidade:
- JSON Server (recomendado pra começar):
npx json-server@latest --watch db.json --port 3001 - Mock Service Worker (MSW) - mais robusto,
intercepta
fetchno browser. Bom pra testes. - API pública - JSONPlaceholder
(
https://jsonplaceholder.typicode.com/todos) - serve pra testar, mas não persiste.
Pra este projeto, JSON Server é suficiente. Crie um
db.jsonna raiz:{ "tarefas": [ { "id": "1", "titulo": "Estudar TanStack Query", "feita": false }, { "id": "2", "titulo": "Fazer o projeto final", "feita": false } ] } - JSON Server (recomendado pra começar):
-
Configure o fetch base. Pra não repetir a URL em cada
queryFn, crie um helper:// src/api.ts const BASE_URL = "http://localhost:3001"; export async function api<T>( path: string, options?: RequestInit, ): Promise<T> { const r = await fetch(`${BASE_URL}${path}`, { headers: { "Content-Type": "application/json" }, ...options, }); if (!r.ok) { throw new Error(`API error: ${r.status} ${r.statusText}`); } return r.json(); }
Requisitos
Funcionais (todos obrigatórios)
useTarefas(hook) - busca a lista de tarefas do servidor. Retorna{ data, isLoading, isError, error }. UseuseQuerycomqueryKey: ["tarefas"].useCriarTarefa- mutation que faz POST e invalida["tarefas"]noonSuccess. UseuseMutationcommutationFn: (nova) => api("/tarefas", { method: "POST", body: JSON.stringify(nova) }).useEditarTarefa- mutation que faz PUT e atualiza a key específica["tarefas", id]noonSuccesscomsetQueryData. Também invalida a lista raiz.useDeletarTarefa- mutation que faz DELETE e remove a key["tarefas", id]noonSuccess. Atualiza a lista removendo o item.useMarcarComoFeita- mutation PATCH com optimistic update:onMutate: cancela refetches, salva snapshot da lista, atualiza o item comfeita: !feitaviasetQueryData, retorna snapshot no context.onError: rollback com o snapshot do context.onSettled: invalida a lista raiz.
UX (todos obrigatórios)
- Loading inicial: mostra "Carregando tarefas..."
enquanto
isLoading. - Erro: mostra mensagem de erro com botão "Tentar
novamente" (chama
refetch). - Optimistic update visível: ao marcar como feita, a UI muda instantaneamente (sem esperar o servidor). Se der erro, volta ao estado anterior + mostra toast.
- Indicador "salvando..." no header quando qualquer
mutation de tarefas está rodando. Use
useMutationStatecomfilters: { mutationKey: ["tarefas"] }. - Filtro funciona sem loading cheio: ao trocar de
"pendentes" pra "feitas", a lista anterior continua
visível (esmaecida) até a nova carregar. Use
placeholderData: keepPreviousData(ouplaceholderData: (anterior) => anteriorem versões mais antigas). - Offline: desligue a rede no DevTools, faça
operações. Mutations devem ficar em estado
pausede retomar quando a rede voltar.
Estrutura de código (recomendado)
Separe em arquivos:
src/api.ts- helper de fetchsrc/hooks/useTarefas.ts- query de listasrc/hooks/useCriarTarefa.ts- mutation POSTsrc/hooks/useEditarTarefa.ts- mutation PUTsrc/hooks/useDeletarTarefa.ts- mutation DELETEsrc/hooks/useMarcarComoFeita.ts- mutation PATCH com optimisticsrc/App.tsx- UI principal com lista + form + filtrossrc/components/ListaTarefas.tsx- render da listasrc/components/FormTarefa.tsx- input + botão de criarsrc/components/IndicadorSalvando.tsx- header comuseMutationState
Cada hook é um arquivo separado. Você importa nos componentes. Isso facilita revisar e testar.
Desafios extras (opcionais, pra ir além)
Cada um cobre um conceito de um nó anterior:
- Persistência offline: com
@tanstack/query-persist-client-coreecreateSyncStoragePersister, persista o cache nolocalStorage. Recarregue a página - o cache deve voltar do storage em vez de refazer todas as requests. - Infinite scroll: troque a lista simples por
useInfiniteQuery, com 20 itens por página. Scroll infinito viaIntersectionObserver. - Dependência entre queries: adicione um detalhe
de tarefa (clicar numa tarefa abre um painel com
comentários). Use
useQuerycomenabledcondicional baseado na query de lista. - Teste E2E: com Playwright, valide o fluxo principal - criar, marcar como feita, deletar. Use o Checkpoint abaixo como referência.
- Type-safe com
queryOptions: refatore os hooks pra usarqueryOptions({ ... })(introduzido no TanStack Query 5.x), que infere tipos automaticamente sem generics verbosos.
Dicas
-
Não invente
queryKeyna hora de invalidar. Extraia pra um arquivosrc/keys.ts:export const tarefasKey = { all: ["tarefas"] as const, detail: (id: string) => ["tarefas", id] as const, };Aí
queryKey: tarefasKey.alleinvalidateQueries({ queryKey: tarefasKey.all }). Sem chance de typo. -
Valide o cache no Devtools a cada mutation. Abra o painel, faça uma operação, veja o status mudar de "fresh" pra "stale" pra "fetching" pra "fresh" de novo. Se algum estado estiver errado, é bug na sua lógica de invalidação.
-
Não use
refetchIntervalem lugar deinvalidateQueries. É tentador "só pra resolver rápido", mas vira requests desnecessárias. O caminho certo é mutation → invalidate → refetch natural. -
Tipe explicitamente o retorno do
api<T>. O TS infereTa partir de quem chama, então odatadouseQueryfica tipado:const { data } = useQuery({ queryKey: ["tarefas"], queryFn: () => api<Tarefa[]>("/tarefas"), }); // data: Tarefa[] | undefined -
Trate
error: unknownem TS 4.4+. TanStack Query tipaerrorcomoErrorpor padrão, mas se você tipar genérico, viraunknown. AdicioneuseQuery<TData, Error>({...})pra ter autocomplete.
Como você sabe que terminou
O projeto está pronto quando:
- Todas as operações CRUD funcionam (criar, listar, editar, deletar, marcar como feita) sem erro no console.
- Optimistic update é instantâneo: marcar como feita muda a UI em < 50ms, mesmo com a rede "lenta" (throttle no DevTools pra "Slow 3G" pra testar).
- Cache se mantém entre navegações: ir pra outra rota e voltar não dispara loading cheio.
- Devtools mostra o ciclo de vida correto: cada
mutation causa
stale→fetching→freshna query correspondente. - Offline funciona: com rede desligada, mutations
ficam
paused. Quando religa, completam. - Indicador "salvando..." aparece e some corretamente.
- Código está separado em hooks (não tudo em um
arquivo
App.tsxde 500 linhas).
Próximo passo depois do projeto
Quando o projeto estiver rodando, você está pronto pra:
- Mergear com a
nextjs: integrar TanStack Query com Server Components, pré-popular o cache no server viadehydrate, evitar loading flash no client. - Aprofundar testes: com
@testing-library/reacte MSW pra mockar a API, escrever testes que verificam queuseQuerychama oqueryFncorreto, e queuseMutationfaz rollback em caso de erro. - Migração pra
useSuspenseQuery: trocar osuseQueryporuseSuspenseQuerye mover os loadings pra<Suspense>na borda. - Trilha
storybook-design-systems: extrair a UI do projeto (lista, form, indicador) pra componentes reutilizáveis com stories e testes visuais.
Dica: o projeto é a parte mais importante da trilha. Ler os nós sem fazer o projeto é como ler sobre cozinhar sem cozinhar. Aprofundamento real vem de encontrar o bug, abrir o Devtools, e ver o que estava errado. Reserve pelo menos 4-6 horas pra fazer com calma, com a
rede do Devtools throttledpra "Slow 3G" durante o desenvolvimento - assim você sente o que o usuário sente com rede ruim, e a importância do optimistic update fica concreta.