Skip to content

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árioExemplo
1Toggle/Switch de estado binário em listaativo↔inativo, destaque, concluída
2Reorder via drag-and-drop (@dnd-kit)Carrossel de headers, kanban
3Delete em lista visívelRemoção de tarefa, banner, template
4Edição inline de campo único em tabelaRenomear inline, ajuste de nota
5Movimentaçã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árioMotivo
1Mutations financeirasUsuário precisa de confirmação real do servidor
2Operações em lote >50 itensRisco de divergência alta entre optimistic e backend
3Efeito colateral irreversívelhard_delete, envio de WhatsApp, transferência em massa
4Wizard/import multi-etapaEstado já é local; não há cache compartilhado
5Resposta carrega dados imprevisíveisServidor gera ID, relacionamentos, valores derivados não computáveis no cliente

Hooks que se enquadram aqui devem ser explicitamente marcados com:

ts
// 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)

ts
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

#RegraRazão
1cancelQueries SEMPRE antes do setQueryData em onMutateEvita refetch concorrente sobrescrever o optimistic
2Snapshot tipado: getQueryData<T[]>(...)Type-safety + previne any implícito
3onSettled sempre invalida a key corretaCache reconverge ao servidor, divergência se autocorrige
4onError SEMPRE restaura snapshot + toast com getUserFriendlyErrorSem rollback = UI mente; sem toast = erro silencioso
5setQueryData é puro (read-modify-write, sem mutação in-place)React precisa de nova referência para re-renderizar
6Para create, item provisório usa id: 'temp-...' ou aguarda servidorSem id provisório, React duplica linha quando servidor responde
7Mutations encadeadas: cada onMutate cuida da sua sliceOrquestrador não precisa de lógica visual
8Não fazer optimistic em campos que só o servidor defineEx.: criado_em, IDs reais, valores derivados de RPC

5. Riscos & mitigações

RiscoMitigação
Race entre 2 mutations rápidascancelQueries em onMutate + React Query enfileira mutations da mesma key
Divergência optimistic vs valor real do servidoronSettled 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 esquecidoLint manual via checklist §7; teste de integração obrigatório verifica rollback

6. Anti-padrões

❌ Anti-padrãoPor quê
setQueryData sem cancelQueriesRefetch 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 onMutateMente para o usuário se a mutation falhar
Optimistic em create sem id provisórioReact duplica a linha quando servidor responde com id real
Esquecer onSettledCache fica desincronizado se optimistic divergir do real
onSuccess chamando setQueryData com a respostaRedundante — onSettled invalida e refetch traz a verdade

7. Checklist de revisão de PR

markdown
□ 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 falha

Padrã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:

ts
// 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.md

Princí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.

#HookMutation(s) elegíveisTipo
1useTarefasEscolaalternarStatusMutation, excluirMutationtoggle, delete
2useMuralEscolatoggleDestaqueMutation, toggleAtivaMutation, excluirMutationtoggle, delete
3useBannersLoginativarMutation, desativarMutation, excluirMutationtoggle, delete
4useGestaoTurmasdeletarMutationdelete
5useGestaoResponsaveisbloquearMutation, desbloquearMutationtoggle
6useTemplatesDataexcluirMutationdelete
7useAnoLetivotoggleMutation (incremento automático)toggle
8useAdminIncidentesresolverMutation (apenas individual)toggle
9useInscricoesOlimpiadaupdateStatusMutation, cancelMutationtoggle

10. Hooks intencionalmente pessimistas (não migrar)

#HookMutation(s)Motivo
1useAdminFaturasmarcarPagoMutation, cancelarMutationfinanceiro
2useAdminUsuariosexcluirMut (hard_delete)irreversível + cross-escola
3useEscolaPagamentosqualquer (futura)financeiro
4useImportacaoResultadosqualquerwizard multi-etapa, estado local
5useTransferenciaAlunostransferirMutationlote crítico, exige confirmação
6useAdminIncidentesresolverLoteMutationlote >20 itens

Referências