Optimistic Updates — Padrão Canônico (SSOT)
Padrão obrigatório de Optimistic Update + Snapshot/Rollback para mutations interativas. Última atualização: 2026-04-24 — Status: SSOT.
Este documento é a única fonte da verdade para optimistic updates no projeto. Toda mutation interativa nova DEVE seguir este padrão. Hooks legados elegíveis carregam comentário // TODO(optimistic-update): ... e devem ser migrados oportunisticamente (boy scout rule), nunca em refactor especulativo.
Implementações de referência: useHeadersNovidades, useControle, useAdminFeatureFlags.
1. Quando aplicar (obrigatório)
Aplique optimistic update quando a mutation atende a qualquer dos critérios abaixo:
| # | Cenário | Exemplo |
|---|---|---|
| 1 | Toggle/Switch de estado binário em lista | ativo↔inativo, destaque, concluída |
| 2 | Reorder via drag-and-drop (@dnd-kit) | Carrossel de headers, kanban |
| 3 | Delete em lista visível | Remoção de tarefa, banner, template |
| 4 | Edição inline de campo único em tabela | Renomear inline, ajuste de nota |
| 5 | Movimentação entre seções/colunas (kanban-like) | Arrastar entre status |
Sintoma sem optimistic: "pisca" — UI volta ao estado antigo entre o evento do usuário e o refetch (~300-800ms). Ver PROBLEM_SOLVING.md Caso 11.
2. Quando NÃO aplicar
| # | Cenário | Motivo |
|---|---|---|
| 1 | Mutations financeiras | Usuário precisa de confirmação real do servidor |
| 2 | Operações em lote >50 itens | Risco de divergência alta entre optimistic e backend |
| 3 | Efeito colateral irreversível | hard_delete, envio de WhatsApp, transferência em massa |
| 4 | Wizard/import multi-etapa | Estado já é local; não há cache compartilhado |
| 5 | Resposta carrega dados imprevisíveis | Servidor gera ID, relacionamentos, valores derivados não computáveis no cliente |
Hooks que se enquadram aqui devem ser explicitamente marcados com:
// NOTE(pessimista-intencional): Optimistic update é INADEQUADO aqui.
// Motivo: <financeiro | irreversível | wizard | lote-grande>
// Ver: docs/development/OPTIMISTIC_UPDATES.md §"Quando NÃO aplicar"3. Anatomia canônica (4-hook)
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { olpToast } from '@/components/ui/use-olp-toast';
import { getUserFriendlyError } from '@/lib/error-helpers';
const QUERY_KEYS = { list: ['meu-modulo'] as const };
const minhaMutation = useMutation({
mutationFn: async (vars: MinhasVars) => {
const res = await invokeAction('minha-funcao', 'minha-acao', vars);
if (!res.success) throw new Error(res.message || 'Erro');
return res.data;
},
// 1️⃣ onMutate — cancela refetch + captura snapshot + escreve cache otimista
onMutate: async (vars) => {
await queryClient.cancelQueries({ queryKey: QUERY_KEYS.list });
const snapshot = queryClient.getQueryData<MeuItem[]>(QUERY_KEYS.list);
queryClient.setQueryData<MeuItem[]>(QUERY_KEYS.list, (old) => {
if (!old) return old;
return old.map((item) =>
item.id === vars.id ? { ...item, ...vars.patch } : item
);
});
return { snapshot }; // contexto para rollback
},
// 2️⃣ onError — restaura snapshot + toast amigável
onError: (err, _vars, ctx) => {
if (ctx?.snapshot) {
queryClient.setQueryData(QUERY_KEYS.list, ctx.snapshot);
}
olpToast.error('Erro', { description: getUserFriendlyError(err) });
},
// 3️⃣ onSuccess — opcional; reservado para toast de sucesso ou efeitos colaterais
// (NÃO precisa setQueryData — onMutate já fez; onSettled reconcilia)
// 4️⃣ onSettled — sempre invalida; cache reconverge ao servidor
onSettled: () => {
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.list });
},
});4. Regras invariantes
| # | Regra | Razão |
|---|---|---|
| 1 | cancelQueries SEMPRE antes do setQueryData em onMutate | Evita refetch concorrente sobrescrever o optimistic |
| 2 | Snapshot tipado: getQueryData<T[]>(...) | Type-safety + previne any implícito |
| 3 | onSettled sempre invalida a key correta | Cache reconverge ao servidor, divergência se autocorrige |
| 4 | onError SEMPRE restaura snapshot + toast com getUserFriendlyError | Sem rollback = UI mente; sem toast = erro silencioso |
| 5 | setQueryData é puro (read-modify-write, sem mutação in-place) | React precisa de nova referência para re-renderizar |
| 6 | Para create, item provisório usa id: 'temp-...' ou aguarda servidor | Sem id provisório, React duplica linha quando servidor responde |
| 7 | Mutations encadeadas: cada onMutate cuida da sua slice | Orquestrador não precisa de lógica visual |
| 8 | Não fazer optimistic em campos que só o servidor define | Ex.: criado_em, IDs reais, valores derivados de RPC |
5. Riscos & mitigações
| Risco | Mitigação |
|---|---|
| Race entre 2 mutations rápidas | cancelQueries em onMutate + React Query enfileira mutations da mesma key |
| Divergência optimistic vs valor real do servidor | onSettled reconcilia em <500ms — usuário não percebe |
Falha do onSettled (rede off) | Cache fica otimista até próximo refetch — aceitável (5min staleTime) |
| Snapshot esquecido | Lint manual via checklist §7; teste de integração obrigatório verifica rollback |
6. Anti-padrões
| ❌ Anti-padrão | Por quê |
|---|---|
setQueryData sem cancelQueries | Refetch concorrente sobrescreve o optimistic |
Snapshot via deep-clone manual (structuredClone, JSON.parse(JSON.stringify(...))) | React Query já entrega referência imutável; clone é desperdício |
Toast de sucesso no onMutate | Mente para o usuário se a mutation falhar |
Optimistic em create sem id provisório | React duplica a linha quando servidor responde com id real |
Esquecer onSettled | Cache fica desincronizado se optimistic divergir do real |
onSuccess chamando setQueryData com a resposta | Redundante — onSettled invalida e refetch traz a verdade |
7. Checklist de revisão de PR
□ onMutate cancela queries (cancelQueries) e captura snapshot
□ setQueryData é puro (read-modify-write, sem mutação in-place)
□ onError restaura snapshot e exibe toast com getUserFriendlyError
□ onSettled invalida a queryKey correta
□ Não aplica optimistic em fluxo financeiro/irreversível/wizard
□ Teste de integração cobre 2 cenários:
- Cache atualiza otimisticamente antes da resolução do request
- Rollback restaura snapshot quando o backend falhaPadrão de teste: ver src/hooks/__tests__/useHeadersNovidades.integration.test.ts.
8. Política de migração de legados
Não há esforço dedicado de migração em massa.
Hooks legados elegíveis estão marcados com:
// TODO(optimistic-update): Migrar para padrão snapshot+rollback ao próximo toque na feature.
// Tipo: toggle | drag | delete | inline-edit
// SSOT: docs/development/OPTIMISTIC_UPDATES.mdPrincípio (boy scout rule): ao editar o hook por qualquer motivo, o desenvolvedor migra a mutation marcada na mesma entrega. Critério mínimo: 2 testes de integração (optimistic aplicado + rollback) acompanham a migração. Após migrar, remover o TODO e a entrada da §9 (tabela viva).
Não abrir PR só para migração proativa — isso introduz risco sem benefício imediato e desvia esforço de features.
Hooks que devem permanecer pessimistas estão marcados com NOTE(pessimista-intencional) para que ninguém os migre por engano.
9. Tabela viva — hooks pendentes de migração
Mantida sincronizada com os comentários
TODO(optimistic-update)no código. Ao migrar, remover a linha.
| # | Hook | Mutation(s) elegíveis | Tipo |
|---|---|---|---|
| 1 | useTarefasEscola | alternarStatusMutation, excluirMutation | toggle, delete |
| 2 | useMuralEscola | toggleDestaqueMutation, toggleAtivaMutation, excluirMutation | toggle, delete |
| 3 | useBannersLogin | ativarMutation, desativarMutation, excluirMutation | toggle, delete |
| 4 | useGestaoTurmas | deletarMutation | delete |
| 5 | useGestaoResponsaveis | bloquearMutation, desbloquearMutation | toggle |
| 6 | useTemplatesData | excluirMutation | delete |
| 7 | useAnoLetivo | toggleMutation (incremento automático) | toggle |
| 8 | useAdminIncidentes | resolverMutation (apenas individual) | toggle |
| 9 | useInscricoesOlimpiada | updateStatusMutation, cancelMutation | toggle |
10. Hooks intencionalmente pessimistas (não migrar)
| # | Hook | Mutation(s) | Motivo |
|---|---|---|---|
| 1 | useAdminFaturas | marcarPagoMutation, cancelarMutation | financeiro |
| 2 | useAdminUsuarios | excluirMut (hard_delete) | irreversível + cross-escola |
| 3 | useEscolaPagamentos | qualquer (futura) | financeiro |
| 4 | useImportacaoResultados | qualquer | wizard multi-etapa, estado local |
| 5 | useTransferenciaAlunos | transferirMutation | lote crítico, exige confirmação |
| 6 | useAdminIncidentes | resolverLoteMutation | lote >20 itens |
Referências
- REACT_QUERY_CACHE.md §9 — exceção ao "nunca
setQueryDatamanual" - STATE_OF_THE_ART.md §2.5 — performance percebida
- NEW_HOOK.md — checklist para hooks novos
- AUDIT.md §3 — verificação @audit
- PROBLEM_SOLVING.md — Caso 11 (flicker pós-drop)
src/hooks/useHeadersNovidades.ts— implementação canônica de referênciasrc/hooks/__tests__/useHeadersNovidades.integration.test.ts— padrão de teste