web-feature-hooks
Como escrever hooks de feature no web do Smaug com TanStack Query v5 — query key em const de módulo, staleTime 30_000, mutations create/update/remove, invalidateQueries + toast.success, onError com ge
Files1
View on GitHubSKILL.md.claude/skills/web-feature-hooks/SKILL.md
GitHubHook de feature (TanStack Query)
Quando usar esta skill
Ao criar/alterar web/features/<f>/hooks/use*.ts. Aqui mora a regra de negócio e o estado de
servidor; o componente só recebe o resultado.
Há quatro formatos em uso — escolha pelo que o hook faz:
| Formato | Exemplo |
|---|---|
| Lista + CRUD num hook só | features/categorias/hooks/useCategories.ts (arquivo canônico) |
| Leitura com view-model | useExpenseMonth, useInstallmentExpense, useRevenueMonth |
| Mutations agrupadas da feature | useExpenseMutations, useRevenueMutations |
| Estado de assistente/formulário | useExpenseWizard, useRevenueWizard |
Arquivo canônico — lista + CRUD
web/features/categorias/hooks/useCategories.ts (trecho; update e remove seguem o mesmo molde):
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { CategoriasService } from "../services/CategoriasService";
import { toast } from "@/shared/hooks/useToast";
import { getApiErrorMessage } from "@/infra/api-error";
const QUERY_KEY = ["categories"];
export function useCategories() {
const qc = useQueryClient();
const query = useQuery({
queryKey: QUERY_KEY,
queryFn: CategoriasService.getAll,
staleTime: 30_000,
});
const create = useMutation({
mutationFn: (name: string) => CategoriasService.create(name),
onSuccess: () => {
qc.invalidateQueries({ queryKey: QUERY_KEY });
toast.success("Categoria criada com sucesso!");
},
onError: (error, variables) =>
toast.error(getApiErrorMessage(error, "Erro ao criar categoria."), {
action: { label: "Tentar novamente", onClick: () => create.mutate(variables) },
}),
});
// update, remove ...
return { ...query, create, update, remove };
}
Regras
"use client"na primeira linha de todo arquivo de hook.- A query key é um
constde módulo, nunca inline. Em código novo, nomeQK. Em inglês. Receitas e Despesas usam um prefixo por natureza, exportado pelo hook do mês e importado pelos demais, e acrescentam sufixos na própria query:
Assim uma única invalidação porexport const QK = ["expenses"]; // useExpenseMonth.ts queryKey: [...QK, "month", competence.year, competence.month]; queryKey: [...QK, "summary", year]; // useExpenseYearSummary queryKey: [...QK, "installment", id]; // useInstallmentExpenseQKatualiza lista, faixa de 12 meses e Fichas abertas. O Dashboard inlineia a chave parametrizada:queryKey: ["dashboard", competence.year, competence.month]. const qc = useQueryClient();— sempre abreviadoqc.staleTime: 30_000em todauseQuery(com separador numérico, não30 * 1000).enabledsó com motivo documentado em comentário — o único caso éuseDataExport, que desliga a prévia enquanto o período é inválido. Não useselectnemplaceholderData.queryFné a referência nua do método do service (queryFn: CategoriasService.getAll) ou uma arrow quando há argumento (queryFn: () => DespesasService.getMonth(competence)).mutationFné referência nua quando o payload casa 1:1; caso contrário é arrow que desestrutura oide espalha o resto, com o tipo inline no parâmetro:mutationFn: ({ id, ...p }: { id: string; endYear: number; endMonth: number }) => DespesasService.terminateRecurring(id, p),- Vocabulário dos nomes de mutation:
create,update,remove(nuncadelete— palavra reservada),addVersion,terminate; quando a feature tem mais de um alvo para o mesmo verbo, o sufixo diz qual (updateOneTime,terminateInstallment). Não existeisCreating. onSuccessinvalida e avisa:qc.invalidateQueries({ queryKey: QK })etoast.success("<mensagem pt-BR>"). Sem update otimista, semsetQueryData. O texto do toast vem da spec da feature — Categorias termina em!; Receitas/Despesas usam o texto exato do PRD, sem!("Despesa adicionada", "Parcelamento encerrado").onErroré arrow concisa (sem chaves) devolvendotoast.error(...):
O fallback sempre começa comonError: (error, variables) => toast.error(getApiErrorMessage(error, "Erro ao <verbo> <substantivo>."), { action: { label: "Tentar novamente", onClick: () => x.mutate(variables) }, }),"Erro ao "e termina com ponto. O rótulo da ação é exatamente"Tentar novamente".- Retorno:
return { ...query, create, update, remove };— o resultado da query é espalhado plano e as mutations ficam aninhadas. O consumidor lêhook.data,hook.isLoading,hook.create.mutate(...),hook.create.isPending. - Defaults globais (retry 3, backoff exponencial,
gcTime,refetchOnWindowFocus: false) vivem eminfra/query-client.ts— não os repita no hook. - Hook de negócio não importa
next/navigation; usa@/infra/router-adapter.
Variantes de Receitas e Despesas
Leitura com view-model
O hook espalha a query e acrescenta campos derivados prontos para a tela, calculados por uma função pura exportada (testável sem React):
export function useExpenseMonth(competence: Competence) {
const query = useQuery({
queryKey: [...QK, "month", competence.year, competence.month],
queryFn: () => DespesasService.getMonth(competence),
staleTime: 30_000,
});
return {
...query,
ledger: query.data ? toExpenseLedger(query.data) : undefined,
errorMessage: query.error
? getApiErrorMessage(query.error, "Erro ao carregar despesas.")
: null,
};
}
errorMessagejá mapeado em pt-BR: o componente nunca chamagetApiErrorMessage.toExpenseLedger,toExpenseRow,toInstallmentSummarysãoexport functionno mesmo arquivo.- Situação de competência,
canDelete,canTerminateeisExpiredvêm do servidor; o hook repassa, não recalcula a partir denew Date().
Mutations agrupadas — useExpenseMutations({ onSuccess })
Todas as escritas da feature num hook só, consumido pelo assistente e pelas Fichas:
/** Invalidação por chave: o Dashboard soma as mesmas despesas e não pode ficar defasado. */
const DASHBOARD_QK = ["dashboard"];
export function useExpenseMutations({
onSuccess,
}: { onSuccess?: (action: ExpenseAction) => void } = {}) {
const qc = useQueryClient();
const succeed = (action: ExpenseAction, message: string) => {
qc.invalidateQueries({ queryKey: QK });
qc.invalidateQueries({ queryKey: DASHBOARD_QK });
toast.success(message);
onSuccess?.(action);
};
// create, updateOneTime, remove, addVersion, terminate, terminateInstallment
}
- Toda mutation invalida o prefixo da natureza e
["dashboard"]— por chave, sem importar a feature dashboard. - O
onSuccessdo chamador fica no hook, não no.mutate(..., { onSuccess }): assim ele também roda quando o usuário repete a ação pelo "Tentar novamente" do toast, e o modal fecha. - O chamador decide pelo
action(a Ficha fecha ao remover e só sai da confirmação ao encerrar). - Criação por tipo recebe uma união discriminada
{ kind, payload }; o toast sai de umRecord<Kind, string>.
Estado de assistente — useExpenseWizard
O React Hook Form fica dentro do hook, não no componente:
const form = useForm<ExpenseWizardValues>({
defaultValues: initialValues(options),
resolver: zodResolver(
expenseDetailsSchema(reference),
) as unknown as Resolver<ExpenseWizardValues>,
});
const mutations = useExpenseMutations({ onSuccess: onDone });
// ...
return {
step,
form,
values: form.watch(),
// Ler `formState.errors` durante o render é o que assina o proxy do RHF.
errors: form.formState.errors,
next,
back,
submit,
isPending: mutations.create.isPending || mutations.updateOneTime.isPending,
};
- O schema é uma fábrica que recebe o
referencedo servidor (para a regra de competência passada). O cast do resolver existe porque o schema é uma união discriminada porkind, enquanto o formulário guarda todos os campos de uma vez para que trocar de tipo não perca o que foi digitado. - Valide só ao sair da etapa (
await form.trigger()), e monte o payload da API emsubmit. - Devolva
errors: form.formState.errors, lido no corpo do hook. Lerform.formState.errorssó no componente, viawizard.form, não assina o proxy e os erros não aparecem. - Prévias (divisão de parcelas, meses afetados) vêm de
@/shared/lib/preview, espelho do domínio.
Hooks compartilhados (não são de feature)
shared/hooks/useCompetenceNavigation— estado de UI puro:{ selected, status, isCurrent, current, navigate, select, goToCurrent }. CompersistKey, guarda o mês emsessionStorage(Dashboard usa sempersistKey).useLedgerNavigation()(mesmo arquivo) — a versão de Receitas/Despesas: persiste emLEDGER_COMPETENCE_KEYe expõeadoptReference(reference), chamado durante o render da tela com oreferenceda primeira resposta.shared/hooks/useSeriesActions— modo, abas, formulários e prévia da Ficha de série, comum a recorrente e fixa; recebe os schemas da feature por parâmetro.
Outras exceções legítimas
useDashboard— só query, devolveuseQuerydireto.useRegister— mutation sem toast e sem invalidação (o formulário renderiza o erro inline). O cadastro já abre a sessão:onSuccessfazsetSession(session.accessToken)erouter.push("/dashboard"). Devolve{ ...mutation, isEmailTaken }, derivado do códigoEMAIL_ALREADY_EXISTS, para o formulário oferecer o link "Entrar".useVersionHistory— desestrutura a query e deriva no cliente, devolvendo um nome de domínio (groups) em vez dedata.useAuth(features/auth/hooks/useAuth.ts) — não usa TanStack Query:useState+useEffect, porque o bootstrap de sessão não pode ser cacheado. Roda no layout de(app)e noLoginForm. Devolve{ user, isAuthenticated, isLoading, error, login, logout, logoutAll }.Bootstrap — retoma a sessão ao abrir o app:
async function resumeSession(): Promise<User | null> { if (getAccessToken()) return AuthService.getMe(); if (hasSessionHint()) return (await refreshSession()).user; return null; }e guarda a regra mais importante do tratamento de erro do front:
.catch((err) => { // Renovação recusada (401): `refreshSession` já apagou o indicador e mandou para o login. if (isInvalidSessionError(err)) { setAuthState({ user: null, isAuthenticated: false }); return; } // Falha transitória (rede fora, 5xx): a sessão continua válida — perdemos // apenas os dados do perfil, que voltam no próximo carregamento. setAuthState({ user: null, isAuthenticated: true }); setError("Não foi possível carregar seu perfil. Tente novamente em instantes."); })login(input, next = null)—AuthService.login→setSession(session.accessToken)→router.push(safeDestination(next)). Onextchega por parâmetro, não poruseSearchParams: no layout de(app)o hook de busca exigiria<Suspense>.safeDestinationéexport functionno mesmo arquivo (testável sem React) e é a defesa contra redirecionamento aberto:export function safeDestination(next: string | null): string { if (!next || !next.startsWith("/") || UNSAFE_CHARS.test(next)) return DEFAULT_DESTINATION; const url = new URL(next, RESOLUTION_BASE); if (url.origin !== RESOLUTION_BASE) return DEFAULT_DESTINATION; return `${url.pathname}${url.search}${url.hash}`; }logout/logoutAll— sair nunca falha para o usuário: chamam a API numtry/catchvazio e, nofinally,endLocalSession()(clearSession()+ estado vazio +redirectToLogin(), cuja troca de documento descarta o cache do React Query).logoutAllpassa por/auth/*, que oapi-clientnão renova; por isso, num 401, ele mesmo chamarefreshSession()e repete uma vez antes de desistir.
Checklist
-
"use client"na primeira linha. -
const QK = [...]no módulo (prefixo exportado, se a feature tem várias queries). -
useQuerycomstaleTime: 30_000. - Mutations nomeadas do vocabulário.
-
onSuccess: invalidate (+["dashboard"]se a feature entra nos totais) +toast.success. -
onError:getApiErrorMessage(error, "Erro ao ….")+ ação"Tentar novamente"que re-dispara comvariables. - Derivações em função pura exportada;
errorMessagepronto para a tela. - Teste em
use<Coisa>.test.tsxcomsetup()local (skillweb-testes). - Todo código de erro novo da API tem entrada em
infra/api-error.ts.
Não faça
- Não coloque
queryKeyinline (salvo query parametrizada). - Não nomeie mutation de
delete. - Não faça update otimista nem
setQueryData. - Não chame
apiClientdireto — passe pelo service da feature. - Não escreva a mensagem de erro à mão: use
getApiErrorMessagecom fallback. - Não recalcule no cliente uma situação que o servidor já informa (competência, parcela, expiração).
- Não passe o
onSuccessque fecha modal no.mutate()— o "Tentar novamente" o perderia. - Não toque em sessão (
setSession/clearSession/refreshSession) fora defeatures/auth/hooks— o resto do app só vê o efeito peloapiClient. - Não leia
?next=comuseSearchParamsnem navegue para ele semsafeDestination.
Inconsistências conhecidas
QKvsQUERY_KEY:useCategories.tsusaQUERY_KEY; as demais features usamQK. Em código novo, useQK.useVersionHistory.tseshared/hooks/useDebounce.tsnão têm"use client"(funciona por acidente, pelo consumidor já ser client). Sempre inclua a diretiva em hook novo.