Catálogo autoral, em português, dos sinais de alerta mais relevantes em codebases React — para code review, entrevistas de senior/staff e auditoria de arquitetura. Consolidado a partir de múltiplas fontes (ver Bibliografia), com comentários próprios e exemplos adaptados. Para fundamentos de React, ver React. Para TS, ver TypeScript. Para testes, ver Testes em JavaScript.
Como usar este manual
Cada item segue o padrão: problema → exemplo ruim → solução com exemplo → nuances e exceções → fonte. Callouts [!info]- colapsáveis trazem definições de conceitos de apoio (shim, polyfill, focus trap, discriminated union, etc.) pra leitura autocontida. A numeração é contínua (1–56) para facilitar citação (“red flag #33”). Use os capítulos como índice e o Checklist para code review como ferramenta rápida.
Filosofia
“Red flag” não é regra absoluta — é sinal de que vale parar e pensar. Todo padrão tem contraexemplo. O valor está em reconhecer, nomear e justificar a escolha.
O problema. Muita codebase acumula dependências que existiam por boa razão há 5 anos, mas que hoje são redundantes porque a plataforma evoluiu. Cada dep extra tem custo real: peso no bundle (afeta LCP e INP), mais uma superfície de CVEs pra auditar, mais uma API pra sua equipe aprender, mais um candidato a peer dependency conflict quando você subir versão do React.
O caso clássico é lodash. A lib foi essencial quando o JS não tinha Array.prototype.flat, Object.entries, ?? (nullish coalescing), spread em objetos. Hoje, quase tudo que lodash oferece tem equivalente nativo — e frequentemente mais performático, porque o engine V8 otimiza as built-ins.
Object.groupBy é ES2024 — precisa de Node 21+ ou browsers recentes. Se seu alvo inclui Safari antigo, cheque caniuse.com/?search=Object.groupByantes de remover o polyfill. Regra: adote nativo quando o alvo de suporte cobre, senão mantenha o shim.
O que é um Shim e um Pollyfill?
Shim: uma função que implementa API moderna usando recursos antigos. Exemplo:
function structuredCloneShim(obj: any) {return JSON.parse(JSON.stringify(obj));}
Pollyfill: um pacote que detecta se a API nativa existe e, se não, registra o shim globalmente. Exemplo:
if (!('structuredClone' in window)) { (window as any).structuredClone = structuredCloneShim;}
Exemplo — substituindo lodash.cloneDeep:
// antes — 24KB gzipped de lodash só pra issoimport cloneDeep from 'lodash/cloneDeep';const copy = cloneDeep(state);// depois — 0KB, e lida com Map/Set/Date nativamenteconst copy = structuredClone(state);
Antes de npm install: cheque caniuse e MDN. Se a feature que você precisa está lá e cobre seu alvo, pense duas vezes antes de adicionar a dep.
Quando a lib ainda vale.debounce/throttle com edge cases (leading, trailing, maxWait) é não-trivial — lodash.debounce isolado ou hooks dedicados (useDebounce, useDebouncedCallback) são ok. Utilitários de imutabilidade complexa (Immer) continuam ganhando do manual. O ponto não é “zero deps”, é deps justificadas.
Fonte: [1]
2. Deps pesadas quando existem alternativas leves
O problema. Algumas libs populares são pesadas por razões históricas (arquitetura não tree-shakable, features legadas). Se existe alternativa moderna de tamanho 10x menor que resolve o seu caso, a troca é quase sempre positiva — especialmente se for dep de primeira camada (carregada no bundle inicial).
date-fns (~13KB se tree-shakable, import por função): import { format, addDays } from 'date-fns'
dayjs (~2KB, API compatível com moment): drop-in replacement
Intl.DateTimeFormat nativo: zero dep pra formatação básica
Temporal (proposta ES, polyfill disponível): o futuro
axios (~13KB gzipped) onde fetch nativo resolve. axios ainda tem valor em casos reais (interceptors, retry, progress) — mas pra 90% dos fetches simples, fetch + pequeno wrapper basta.
uuid — ~5KB pra gerar IDs quando crypto.randomUUID() é uma linha.
react-icons completo em vez de ícones individuais (lucide-react com tree-shaking, ou SVG inline).
Exemplo — substituindo moment por Intl.DateTimeFormat:
// antesimport moment from 'moment';const formatted = moment(date).format('DD/MM/YYYY HH:mm');// ❌ 70KB no bundle só pra isso// depoisconst formatter = new Intl.DateTimeFormat('pt-BR', { dateStyle: 'short', timeStyle: 'short',});const formatted = formatter.format(date);// ✅ 0KB, e respeita locale do usuário automaticamente
Ferramentas pra decidir.
bundlephobia.com — tamanho de qualquer pacote npm com gráfico histórico
pkg-size.dev — inclui análise de exports individuais
bundle analyzer — visualiza o que tá pesando no seu bundle real
Regra de bolso
Se uma dep ocupa mais de 5% do bundle inicial e você usa menos de 20% da API dela, investigue alternativa.
Fonte: [1]
3. Sem linter nem formatter
O problema. Sem ferramenta automática garantindo estilo e qualidade, cada PR vira discussão de vírgula, ponto-e-vírgula, const vs let. Pior: bugs reais passam despercebidos porque o ruído cognitivo do review é alto demais pra ver os problemas que importam. Lint não é preferência estética — é automação de code review.
O que linter (ESLint/Biome) pega que um humano cansado não pega:
Variável não usada → pode ser sinal de import errado ou código morto
useEffect sem deps corretas (react-hooks/exhaustive-deps) — principal defesa contra stale closure (ver #26)
any implícito (@typescript-eslint/no-explicit-any)
== em vez de ===
Regras de acessibilidade (jsx-a11y/*)
Ordem de imports, console.log esquecido, código inalcançável
Alternativa moderna: Biome. Formatter + linter em Rust, 10-50x mais rápido que ESLint + Prettier, config única. Se tá começando projeto novo, considere.
CI — última linha de defesa. Build quebra se lint falhar. Impede bypass via git commit --no-verify.
Não desligue regras por conveniência
// eslint-disable-next-line pra calar o linter é red flag em si (ver #23). Se a regra tá errada pro seu caso, configure a regra no .eslintrc explicando por quê — documentado, versionado, revisável. Comentário inline é dívida invisível.
Fonte: [1]
Cap. 2 — Organização de código
4. Estrutura de pastas inconsistente
O problema. Metade dos componentes em src/components/, metade em src/ui/, alguns em src/views/, feature nova em src/features/. Cada PR negocia onde as coisas vão. Desenvolvedor novo gasta a primeira semana arqueologizando o layout do repo em vez de produzir. Pior: arquivos “órfãos” se escondem em diretórios esquecidos e ninguém sabe se podem deletar.
Isso não é questão estética — estrutura consistente é navegação. Em repo bem organizado, você consegue adivinhar onde um arquivo mora antes de abrir o VSCode.
Duas convenções viáveis.
# Feature-based (o que mais escala em SPAs médias/grandes)src/├── features/│ ├── auth/│ │ ├── components/│ │ ├── hooks/│ │ ├── api.ts│ │ └── types.ts│ ├── checkout/│ └── dashboard/├── shared/ ← código genuinamente reusável entre features│ ├── ui/ ← Button, Input, Modal...│ └── lib/ ← date, string, http...└── app/ ← rotas, providers, entry point
# Domain-based (mais comum em backends; usado em frontends com modelos fortes)src/├── users/├── orders/├── products/└── shared/
O que é "feature-based" vs "domain-based"?
Feature-based: organiza por funcionalidade do produto (auth, checkout, dashboard). Cada feature é auto-contida. Boa pra SPAs cujas funcionalidades são relativamente independentes.
Domain-based: organiza por entidade do modelo de negócio (users, orders, products). Herdada de arquitetura DDD. Boa quando o app é CRUD sobre entidades bem definidas — múltiplas features operam sobre as mesmas entidades.
Não são excludentes: pode ter features/checkout/ que consome domain/orders/. Mas escolha uma como raiz e documente.
Regra prática. Escolha uma convenção, documente no README.md com exemplos, e passe review rigoroso nos primeiros 10 PRs pra segurar. Depois vira cultura.
Sinal de piora silenciosa. Se você tem components/, views/, ui/, widgets/, pages/todos ao mesmo tempo — pare. A equipe não tem modelo mental compartilhado do que é o quê.
Fonte: [1]
5. utils.ts como gaveta de tranqueiras
O problema.utils.ts (ou helpers.ts, ou lib.ts) começa com formatDate. Seis meses depois tem formatDate, slugify, parseCurrency, debounce, getUserInitials, isValidEmail, truncate, deepEqual, classNames, sleep, retry, randomId. Vira o junk drawer do repo:
Hotspot de merge conflict — todo mundo edita o mesmo arquivo.
Sem coesão — funções sem relação entre si, nenhum tema unificador.
Difícil de descobrir — ninguém procura formatCurrency em utils.ts; cada dev reimplementa.
Import cascata — utils.ts importa de 30 lugares, e bundlers menos espertos acabam puxando mais código do que precisam.
Cada arquivo tem uma razão pra existir. Quando você precisa de formatCurrency, sabe que está em currency.ts. Merge conflicts diminuem porque trabalho de domínios diferentes toca arquivos diferentes.
Teste do arquivo utils
Abra seu utils.ts. Se não conseguir escolher um nome curto (2 palavras) que descreva o conteúdo todo, o arquivo tá fazendo coisas demais. Divida.
Quando utils.ts ainda vale. Protótipos, scripts, projetos de um-só-dev. O problema aparece quando o repo tem mais de 2 pessoas editando ativamente.
Fonte: [1]
6. Arquivos relacionados não colocalizados
O problema. Convenção herdada de projetos antigos: separação por tipo de arquivo em vez de feature.
Pra entender uma feature, você pula entre 4 pastas. Pra renomearUserCard → MemberProfile, tem que lembrar de cada uma. Código relacionado parece não-relacionado porque está fisicamente distante. Quando deleta a feature, quase sempre esquece arquivos órfãos.
Solução — colocalize tudo que muda junto.
src/features/members/UserCard/├── UserCard.tsx├── UserCard.test.tsx├── UserCard.module.css├── useUserCard.ts ← hook específico desta feature├── UserCard.types.ts└── index.ts ← re-exporta o componente público
Princípio. Arquivos que mudam juntos moram juntos. É o mesmo raciocínio por trás do princípio de colocalização do Kent C. Dodds: “o que muda junto, fica junto”. Quando for deletar a feature, rm -rf UserCard/ e pronto.
E testes? Ficam junto do código?
Sim — Component.tsx + Component.test.tsx na mesma pasta. Duas razões:
Descoberta: quem edita o componente vê que existe teste e, idealmente, atualiza.
Coesão: teste sem componente é órfão; componente sem teste é visível.
A exceção são testes E2E (Playwright, Cypress) que testam jornadas inteiras — esses ficam numa pasta própria (e2e/, tests/e2e/) porque não pertencem a nenhum componente específico.
Quando separar faz sentido.shared/ui/ genuinamente usado por todas as features, gerador de código (OpenAPI → src/generated/), assets públicos (public/). Esses podem ficar em pastas globais — a regra de colocalização é pro código de feature.
Fonte: [1]
7. Barrel files com export *
O problema. Barrel file é um index.ts que reexporta o conteúdo de arquivos irmãos pra permitir imports limpos:
// src/shared/ui/index.tsexport * from './Button';export * from './Input';export * from './Modal';// ... 40 linhas assim
Permite escrever import { Button } from '@/shared/ui' em vez de from '@/shared/ui/Button'. Parece elegante, mas tem custos reais:
Quebra tree-shaking em vários bundlers. Quando você importa { Button } do barrel, bundlers menos agressivos incluem o código de Modal, Input, etc. no bundle final. Vite e Rollup modernos costumam dar conta; Webpack antigo ou combinações de plugins estranhos, não.
Hot-reload mais lento. Qualquer edição em qualquer arquivo que o barrel reexporta invalida todo mundo que importa do barrel. Dev server trava.
Ciclos de import invisíveis.A/index.ts exporta A1, que importa B do barrel B/index.ts, que exporta B1, que importa A do barrel… ciclo. Roda o app e dá erro esotérico Cannot access 'X' before initialization.
“Go to definition” vai pro barrel, não pro arquivo real. +1 clique em cada navegação.
Refactor ferra. Renomear Button → SolidButton via IDE pode ou não pegar o reexport genérico export * from './Button'.
O que é tree-shaking?
Processo do bundler que remove código não usado do bundle final. Funciona analisando os import/export estáticos: se você importou só format de date-fns, o bundler não inclui parseISO, addDays, etc.
Tree-shaking depende de: (1) módulos ES (import/export, não CommonJS require), (2) ausência de side effects declarados ("sideEffects": false no package.json), (3) bundler que faz análise estática agressiva (Rollup > Vite > esbuild > Webpack > antigos).
Barrel files podem interferir porque o bundler tem que seguir export * e inferir o que cada arquivo exporta — trabalho extra, nem sempre bem feito.
Solução.
Evite barrels desnecessários. Import direto é melhor: import { Button } from '@/shared/ui/Button'.
Se precisar de barrel (API pública de um pacote, por exemplo), use exports nomeados explícitos, não export *:
// bom — explícito, tree-shakable, grepávelexport { Button } from './Button';export { Input } from './Input';export { Modal } from './Modal';// ruimexport * from './Button';export * from './Input';export * from './Modal';
Marque "sideEffects": false no package.json (ou em arquivos específicos) pra sinalizar ao bundler que tree-shaking é seguro.
Quando barrels valem. Bibliotecas publicadas no npm (a API pública precisa ser um ponto único). Módulos pequenos (< 5 exports) onde o custo é mínimo. Features internas com interface pública clara (features/checkout/index.ts exportando só CheckoutPage, escondendo o resto).
Fonte: [1]
Cap. 3 — Componentes e composição
8. God components
O problema. Componente de 800 linhas que faz fetch de dados, filtra, ordena, paginação, renderiza tabela com linhas expansíveis, abre modal de edição, contém form com 12 campos, valida form, submete, trata erros, atualiza cache. Tudo num arquivo só, tudo num componente só.
Sintomas:
Qualquer mudança é alto risco — adicionar coluna na tabela pode quebrar o form, porque state local está entrelaçado.
Testes impossíveis — pra testar validação do form, você tem que mockar o fetch, o modal, o roteador e o toast.
Reuso zero — a tabela dali não serve em outro lugar porque está acoplada ao state do pai.
Medo de refatorar — ninguém entende o arquivo inteiro, então ninguém toca.
God component é quase sempre consequência de crescer sem extrair: cada feature nova foi “mais uma função aqui, mais um estado ali”, e após 18 meses você tem um monstro.
Solução — três cortes clássicos.
1. Extrair sub-componentes por responsabilidade visual.
// antes — tudo num arquivofunction Dashboard() { // 80 linhas de state, fetch, filtros... return ( <div> {/* 200 linhas de header */} {/* 300 linhas de tabela */} {/* 150 linhas de modal */} {/* 100 linhas de form */} </div> );}// depois — composição clarafunction Dashboard() { return ( <DashboardLayout> <DashboardHeader /> <UserTable /> <EditUserModal /> </DashboardLayout> );}
3. Padrão container/presenter (quando faz sentido).
// UserTableContainer.tsx — busca dados, lida com estadofunction UserTableContainer() { const { data, isLoading, error } = useUsers(); if (isLoading) return <Skeleton />; if (error) return <ErrorState error={error} />; return <UserTableView users={data} />;}// UserTableView.tsx — só renderiza, fácil de testar no Storybookfunction UserTableView({ users }: { users: User[] }) { return <table>{/* ... */}</table>;}
Container vs Presenter — ainda vale?
Padrão popularizado em 2015 (Dan Abramov), hoje menos rígido. A essência é: separar quem busca dados de quem desenha pixels. O presenter é puro (só props in, JSX out) e vira “material pronto” pro Storybook, testes visuais e reuso.
Hooks customizados frequentemente substituem o container. Não force o padrão — use quando a separação clareia, ignore quando vira burocracia.
Limite prático. Se o arquivo passou de ~300 linhas, pare e avalie. Raro um componente React honesto precisar de mais que isso. Se precisa, provavelmente são responsabilidades distintas disfarçadas.
Fonte: [1]
9. Passar objeto inteiro quando só precisa de campos
O problema. Quando você passa um objeto enorme como prop porque o filho usa 2 campos, três coisas quebram:
Acoplamento excessivo. O filho agora depende da forma completa do objeto. Renomear user.avatarUrl pra user.profile.avatar te obriga a mudar o filho, mesmo que ele só use o nome.
Re-render desnecessário. Se o pai atualizar qualquer campo de user (mesmo um que o filho não usa), o filho re-renderiza. Com React.memo, a comparação default é shallow — objeto diferente = render.
Tipagem vaga.Avatar dizendo “eu preciso de um User inteiro” é menos preciso que “eu preciso de name e imageUrl”.
Benefício colateral: reusabilidade.Avatar({ name, imageUrl }) serve pra usuário, produto, empresa, qualquer coisa com nome e imagem. Avatar({ user }) só serve pra coisa com User.
Quando passar objeto inteiro faz sentido.
Entity components — <UserCard user={user} /> onde o componente representa conceitualmente aquela entidade e usa vários campos.
Formulários — <UserForm initialValues={user} onSubmit={...} /> onde o form opera sobre a entidade inteira.
Quando a lista de campos passaria de 5–6 props — ponto em que a explosão de props piora a legibilidade. Considere o objeto inteiro ou agrupar em sub-objetos (<Chart data={chartData} config={chartConfig} />).
Regra: pense no componente como contrato público. Se o contrato “o que eu preciso?” é pequeno, props pequenas. Se é grande e conceitualmente unificado, objeto faz sentido.
Fonte: [1]
10. Definir componente dentro de outro componente
O problema. Parece um refactor inocente — “Child só é usado aqui, vou defini-lo dentro do Parent”. Mas React identifica componentes por referência de função. Toda vez que Parent renderiza, function Child(...) cria uma função nova na memória — e pra React, “função nova” significa “tipo de componente diferente”.
Consequências em cascata:
Child remonta a cada render do Parent. State interno (useState) é zerado, effects (useEffect) re-executam do zero, refs perdem referência.
Foco perdido em inputs. Input que tem state interno (controlado ou não) pisca — perde foco, seleção, valor digitado a meio.
Animações reiniciam. Enter/exit animations disparam a cada render.
Performance degrada. Mesmo sem state, é trabalho gratuito: desmontar nó do DOM e montar de novo.
Exemplo mostrando o bug.
// ruim — Child é redefinido a cada render de Parentfunction Parent() { const [count, setCount] = useState(0); function Child() { const [text, setText] = useState(''); return <input value={text} onChange={e => setText(e.target.value)} />; } return ( <div> <button onClick={() => setCount(c => c + 1)}>{count}</button> <Child /> {/* digita no input, clica no botão → input zera. Por quê? Porque `Child` é uma função nova, então React desmonta o input antigo e monta um novo, com state zerado. */} </div> );}
Se Child precisa de dados do Parent, passe via props. Se é tão específico que não merece ser componente, inline o JSX direto:
// alternativa — se realmente é só um pedaço do Parent, não vire componentefunction Parent() { return ( <div> <span>{/* o que seria o Child */}</span> </div> );}
O linter pega isso
A regra react/no-unstable-nested-components (do eslint-plugin-react) detecta esse padrão. Se não está habilitada no seu projeto, habilite.
Quando é ok — nunca. Genuinamente não há caso em que definir componente dentro de componente seja a resposta certa. Se precisa de fechamento sobre variáveis do pai, extraia pra fora e passe via props.
Fonte: [5]
11. Prop drilling excessivo
O problema. “Prop drilling” é passar uma prop por vários níveis da árvore só pra chegar num descendente profundo que precisa dela. Cada componente intermediário carrega uma prop que não usa, só repassa.
// cinco níveis pra o user chegar no Avatarfunction App() { const [user] = useUser(); return <Layout user={user} />;}function Layout({ user }) { return <Header user={user} />; }function Header({ user }) { return <Nav user={user} />; }function Nav({ user }) { return <UserMenu user={user} />; }function UserMenu({ user }) { return <Avatar user={user} />; }
Problemas:
Acoplamento em cascata. Mudar a forma de user força mudar 5 componentes.
Componentes intermediários poluídos.Layout, Header, Nav carregam uma prop que não é problema deles.
Testes mais pesados. Cada intermediário precisa receber user mock pra renderizar.
Refactor assustador. Adicionar theme ao fluxo exige tocar todos eles de novo.
Soluções, em ordem de preferência.
1. Composição via children (solução mais elegante quando cabe).
Em vez de passar o dado pelos níveis, deixe o nível que tem o dado renderizar o nível que precisa dele.
function App() { const [user] = useUser(); return ( <Layout> <Header> <Nav> <UserMenu> <Avatar user={user} /> </UserMenu> </Nav> </Header> </Layout> );}// Layout, Header, Nav, UserMenu não sabem nada de user.
2. Context (quando o dado é “ambiental” — usado em muitos lugares).
Store faz sentido quando: (1) dado é compartilhado entre muitas partes da árvore, (2) mutações acontecem de vários lugares, (3) você precisa de persistência (localStorage), devtools, time-travel, etc.
Quando Context não é suficiente?
Context re-renderiza todos os consumidores quando o value muda — mesmo os que só usam um pedaço. Se você tem 50 componentes lendo de um Context e o valor muda 20 vezes por segundo, é re-render demais.
Stores (Zustand, Jotai, Redux) permitem seletores: cada consumidor re-renderiza só quando o pedaço que ele observa muda. Pra state compartilhado de alto tráfego (cursor, drag-and-drop, animações), stores ganham.
Regra prática. 2 níveis de prop drilling é ok. 3 níveis é alerta. 4+ níveis é refactor. Não sirva Context pra tudo — só quando o dado é genuinamente “ambiental” (usuário logado, tema, locale), não pra evitar pensar em composição.
Fonte: [5][6]
Cap. 4 — Estado e refs
12. Guardar valor derivado em state
O problema. Guardar em useState um valor que pode ser calculado a partir de outro state ou prop. O valor vira uma cópia sincronizada manualmente — e sincronizar é uma das fontes mais comuns de bug em React.
// ruim — duas fontes de verdade pra mesma informaçãofunction UserCard({ firstName, lastName }) { const [fullName, setFullName] = useState(`${firstName} ${lastName}`); // ... e agora? quem atualiza fullName quando firstName muda? // você vai querer um useEffect — e aí já está em terreno pantanoso return <h1>{fullName}</h1>;}
Consequências:
Stale values. Props mudam, state fica pra trás até algum effect atualizar — passa render desatualizado.
Inconsistência. Se dois lugares atualizam firstName mas só um lembra de atualizar fullName, bug.
Solução — derive no render.
// bom — uma fonte de verdade, sempre atualizadofunction UserCard({ firstName, lastName }) { const fullName = `${firstName} ${lastName}`; // cálculo puro return <h1>{fullName}</h1>;}
Render em React é barato. A cada render, fullName é recalculado — e isso é o que você quer.
E se o cálculo for caro? Aí sim: useMemo.
function Table({ items, filter }) { // heavy() custa caro — memoize com base nas entradas reais const filtered = useMemo(() => heavy(items, filter), [items, filter]); return <ul>{filtered.map(/* ... */)}</ul>;}
Mas comece sem useMemo. Só adicione quando profiling mostrar que aquele cálculo é o gargalo. Memoização prematura é custo (memória, complexidade de deps) sem benefício.
Regra de ouro. Se o valor pode ser calculado de state/props, ele não é state. É um cálculo.
"Fonte única da verdade" (single source of truth)
Princípio: cada peça de dado tem um lugar canônico onde vive. Cópias derivadas sempre se calculam a partir desse lugar, nunca guardam cópia.
Em React: state local, Context, store (Zustand/Redux), ou props vindo do pai são fontes. Tudo mais é derivado — calcule no render, não copie pra outro state.
Fonte: [1][2]
13. Usar state quando deveria ser ref
O problema. Nem todo valor que muda precisa causar re-render. State (useState) causa re-render. Ref (useRef) não causa. Usar state pra valores que o render não precisa “ver” é desperdício e pode gerar render infinito.
O que deveria ser ref, não state:
Timer IDs (setTimeout, setInterval) — você precisa lembrar pra limpar, mas UI não depende disso.
Valor anterior de uma prop/state (pra comparar com o atual).
Flag “já montou” ou “já executou uma vez”.
Referência a elemento DOM — ref={meuRef} é ref, não state.
Cache de valor computado que você controla manualmente.
Instância de classe — new Map(), new IntersectionObserver(), WebSocket, AbortController.
Exemplo — debounce ID.
// ruim — state pra algo que UI nem renderizafunction Search() { const [timeoutId, setTimeoutId] = useState<number | null>(null); const handleChange = (e) => { if (timeoutId) clearTimeout(timeoutId); const id = setTimeout(() => fetch(e.target.value), 300); setTimeoutId(id); // ← re-render inútil };}// bom — reffunction Search() { const timeoutRef = useRef<number | null>(null); const handleChange = (e) => { if (timeoutRef.current) clearTimeout(timeoutRef.current); timeoutRef.current = setTimeout(() => fetch(e.target.value), 300); };}
Exemplo — instância mutável que persiste entre renders.
// bom — AbortController vive na ref, não recria a cada renderfunction useFetchOnMount(url: string) { const controllerRef = useRef<AbortController | null>(null); useEffect(() => { controllerRef.current = new AbortController(); fetch(url, { signal: controllerRef.current.signal }); return () => controllerRef.current?.abort(); }, [url]);}
Quando usar useRef vs useState
Pergunta chave: a mudança desse valor deve re-renderizar o componente?
Sim → useState. (Contador que aparece na tela, texto digitado, item selecionado.)
Não → useRef. (Timer ID, flag interna, referência a DOM, contador de cliques que só é lido num handler.)
Casos de fronteira: se o valor afeta outros effects mas não o JSX, ainda é state (deps são reativos a state, não a ref).
Refs não são “state preguiçoso”. Mudanças em ref.current não disparam re-render. Se você mudar a ref e esperar que a UI reflita, nada acontece até algum outro gatilho renderizar.
Fonte: [1]
14. Jogar tudo num store global
O problema. Uma vez que o projeto instala Zustand/Redux/Jotai, a equipe tende a jogar tudo lá. Em 6 meses, você tem 40 slices — e metade é estado que só um componente usa. Problemas:
Acoplamento global artificial. Estado que era local agora é parte da “API” global do app.
Testes mais pesados. Componente que deveria ser testável isoladamente precisa configurar store mock.
Performance. Qualquer subscriber mal configurado re-renderiza em mudança de campo não relacionado.
Navegação piora. Pra entender de onde vem um valor, você caça redutores/slices em vez de subir a árvore de componentes.
Hierarquia do estado (do mais local ao mais global):
State derivado — calcule no render. Não é estado.
useState / useReducer local — default para estado de UI.
“Lift state up” — mover pra ancestral comum quando 2+ filhos compartilham.
Context — quando o estado é “ambiental” e lido de vários lugares da árvore.
Server state (React Query, SWR) — estado vindo do servidor tem cache, dedup, revalidação — não é seu estado, é cache do servidor.
Store global (Zustand, Redux, Jotai) — quando realmente é global e muitas fontes mutam.
Regra prática. Comece em 2 (state local). Suba na hierarquia apenas quando houver pressão real: múltiplos consumidores distantes, persistência necessária, devtools/time-travel valiosos, performance de re-render (seletores granulares do store).
Server state ≠ client state
Antes de colocar dados de API num store global, considere TanStack Query ou SWR. Eles resolvem cache, dedup, revalidação, paginação, mutation — coisas que você vai implementar mal no Redux.
Fonte: [1]
15. Um Context gigante re-renderizando meia app
O problema. Context é reativo ao value inteiro — se o value muda, todos os consumidores re-renderizam, mesmo quem só usa uma parte. Então:
// ruim — mudar theme faz quem só consome user re-renderizartype AppContext = { user: User; theme: Theme; settings: Settings; cart: Cart;};<AppContext.Provider value={{ user, theme, settings, cart }}> {children}</AppContext.Provider>;
Se o carrinho tem 20 mudanças por segundo (item sendo arrastado, por exemplo), o ThemeToggle re-renderiza 20 vezes por segundo sem motivo.
Solução — divida por domínio.
// bom — um Context por preocupação<UserContext.Provider value={userValue}> <ThemeContext.Provider value={themeValue}> <CartContext.Provider value={cartValue}> <SettingsContext.Provider value={settingsValue}> {children} </SettingsContext.Provider> </CartContext.Provider> </ThemeContext.Provider></UserContext.Provider>
Agora mudanças no cart só afetam quem consome CartContext. A árvore cresce um pouco, mas normalmente você cria um componente <AppProviders>{children}</AppProviders> que agrupa.
Segunda otimização — split entre state e dispatch.
Padrão usado por libs (e pelo próprio Context do React em exemplos avançados): dois contexts — um pro valor, outro pros updaters.
const ThemeValueContext = createContext<Theme>('light');const ThemeDispatchContext = createContext<(t: Theme) => void>(() => {});function ThemeProvider({ children }) { const [theme, setTheme] = useState<Theme>('light'); return ( <ThemeValueContext.Provider value={theme}> <ThemeDispatchContext.Provider value={setTheme}> {children} </ThemeDispatchContext.Provider> </ThemeValueContext.Provider> );}// Quem só quer TROCAR tema (não ler) não re-renderiza quando o tema muda:function ThemeToggle() { const setTheme = useContext(ThemeDispatchContext); return <button onClick={() => setTheme('dark')}>Dark</button>;}
Terceira otimização — memoize o value. Ver [[#19-não-memoizar-value-de-context|19. Não memoizar value de Context]].
Por que Context re-renderiza "meia app"?
Todo componente que chama useContext(X) está “inscrito” em X. Quando o Provider muda value (comparação Object.is), React re-renderiza todos esses componentes — ignorando React.memo no caminho.
Object.is é referência pra objetos. Se você passar { user, theme } inline no Provider, é objeto novo a cada render do Provider, então Object.is dá false e todos os consumidores re-renderizam. Por isso memoizar o value é crítico.
Fonte: [1][4]
16. Resetar state manualmente em vez de usar key
O problema. Você tem um componente <Profile userId={...} /> com state interno (comentário rascunhado, tab selecionada, scroll position). Quando userId muda, você quer zerar esse state. O instinto é usar useEffect:
Stale flash. React renderiza com valores antigos primeiro, depois o effect roda e chama setState, o que dispara segundo render com valores corretos. O usuário pode ver o comentário anterior por um frame.
Esquecer um state. Você tem 4 useStates? Precisa lembrar de zerar todos. Adicionou um quinto? Fácil esquecer.
Esforço mental. Toda vez que adiciona state, precisa pensar “preciso zerar isso também?“.
Solução — key prop. React usa key pra identificar instâncias. Quando key muda, React desmonta e remonta o componente — state é zerado naturalmente, effects re-executam, é como se fosse primeira vez.
// bom — key força remount, todo state interno reseta<Profile userId={userId} key={userId} />
function Profile({ userId }) { // state começa "zerado" sempre que a key muda — sem nenhum effect extra const [comment, setComment] = useState(''); const [selectedTab, setSelectedTab] = useState('overview'); // ...}
Onde esse padrão brilha:
Form de edição de entidade — <EditForm itemId={id} key={id} />. Mudar de item reseta tudo.
Modal que reabre — <Modal key={openCount}> pra garantir estado limpo a cada abertura.
Páginas de detalhe — <UserProfile key={userId} /> faz roteamento cliente funcionar como se fosse full page reload (em termos de state).
Cuidado — key tem custo. Remount desmonta o DOM e monta de novo. Se o componente é pesado (gráfico grande, mapa), isso pode piscar. Na dúvida, meça. Pra casos pesados, considere state local condicional mesmo.
key não é só pra listas
Conhecida no contexto de array.map(item => <Row key={item.id} />), mas funciona em qualquer componente. keyidentifica a instância — quando muda, React trata como componente diferente.
Fonte: [2]
17. Ajustar state baseado em props via Effect
O problema. Variação do #16 e do #28. Você precisa que “quando items mudar, selection volte pra null” (ou pro primeiro item, etc). Cai no padrão:
Mesmos problemas: render extra, stale flash, acoplamento sutil.
Três soluções, em ordem de preferência.
1. Calcule no render (se selection é derivável).
// se a seleção é "o primeiro item", derivefunction List({ items }) { const selection = items[0] ?? null; return /* ... */;}
2. key pra remount (se a “identidade” do componente muda quando items muda).
// se items mudando significa "lista diferente", use key<List items={items} key={listId} />
3. Guarde ID em state, derive o objeto no render.
// bom — state guarda apenas ID (primitivo, estável)function List({ items }: { items: Item[] }) { const [selectedId, setSelectedId] = useState<string | null>(null); // se o selectedId não existe mais em items, é null (derivado, sem effect) const selected = items.find(i => i.id === selectedId) ?? null; return ( <ul> {items.map(item => ( <li key={item.id} onClick={() => setSelectedId(item.id)}> {item.name} </li> ))} </ul> );}
Esse padrão (guardar ID, derivar objeto) é poderoso:
Se o item sumiu de items (foi deletado), selected vira null automaticamente.
Se o item foi atualizado (mesmo ID, nome novo), selected reflete o novo dado.
Zero effects. Zero sincronização.
Heurística de decisão
Sempre que sentir vontade de escrever useEffect(() => setX(...), [y]), pare e pergunte: “existe uma fórmula que derive x de y?” Quase sempre existe.
Fonte: [2]
Cap. 5 — Memoização
Fundamento — por que memoização importa em React
React compara props com referência (Object.is), não valor. Se a prop é { x: 1 } hoje e { x: 1 } amanhã mas são objetos diferentes na memória, React considera “mudou” e re-renderiza.
useMemo, useCallback e React.memo existem pra preservar referência entre renders quando o conteúdo não mudou. Sem isso, quebra otimizações (React.memo), dispara effects sem razão (deps instáveis), re-renderiza árvores grandes.
React 19 Compiler promete automatizar muito disso — mas ainda não é default em todo projeto. Até lá, entender memoização manual é obrigatório pra perf de verdade.
18. Memoização quebrada por default inline
O problema. Defaults escritos inline são recriados a cada render — e quando usados como dep de useMemo/useEffect, destroem a memoização.
// ruim — `items ?? []` cria array novo a cada renderfunction Table({ items }: { items?: Item[] }) { const rows = useMemo(() => process(items ?? []), [items ?? []]); // ^^^^^^^^^^^ // array novo a cada render → useMemo nunca cacheia return /* ... */;}
Aqui [items ?? []] é uma expressão avaliada a cada render. Se items é undefined, o fallback [] é um array literal novo, referência diferente, memo inválida. A função process roda toda vez.
EMPTY é criado uma vez quando o módulo carrega. Mesma referência em todos os renders.
Solução 2 — default no parâmetro da função.
// também bom — JS avalia default só quando `items` é undefinedfunction Table({ items = [] as Item[] }: { items?: Item[] }) { // ainda assim, o `[]` é novo a cada render — então: const rows = useMemo(() => process(items), [items]); // funciona **se** items vier sempre do pai (estável). // Mas se o pai passar undefined e o default criar novo []... // bug sutil.}
Prefira a solução 1 — constante fora do componente. Tira ambiguidade.
Mesma armadilha com objetos
const user = props.user ?? {} dentro do componente cria objeto novo quando props.user é undefined. Se user é dep de effect, effect roda toda vez.
Fonte: [1]
19. Não memoizar value de Context
O problema. O value de um Provider é objeto recriado a cada render do Provider. Se você escrever value={{ user, setUser }}, esse objeto é novo todo render — e todos os consumidores re-renderizam, mesmo quando user e setUser não mudaram.
// ruim — objeto novo a cada renderfunction UserProvider({ children }) { const [user, setUser] = useState<User | null>(null); return ( <UserContext.Provider value={{ user, setUser }}> {/* ^^^^^^^^^^^^^^^^^^^^^ */} {/* novo objeto → todos re-renderizam */} {children} </UserContext.Provider> );}
Solução — memoize.
// bom — referência estável enquanto user não mudarfunction UserProvider({ children }) { const [user, setUser] = useState<User | null>(null); const value = useMemo( () => ({ user, setUser }), [user], // setUser é estável (vem de useState), não precisa declarar ); return <UserContext.Provider value={value}>{children}</UserContext.Provider>;}
Agora value só muda quando user muda. Consumidores que só usam setUser ainda re-renderizam (limitação fundamental de Context unificado) — pra resolver isso, split state/dispatch (ver 15. Um Context gigante re-renderizando meia app).
Por que setUser é "estável"?
React garante que a função retornada por useState (o setter) tem referência estável entre renders. Mesmo princípio pra useReducer’s dispatch. Por isso setters não precisam ser declarados em deps — o linter react-hooks/exhaustive-deps reconhece isso.
Regra. Todo Provider deve ter value memoizado. Não é otimização prematura — é corretude (pra não cancelar efeitos de React.memo na árvore).
Fonte: [1]
20. Passar objetos/arrays inline como props
O problema. Sempre que você escreve <Component prop={{ key: value }} />, esse objeto literal é criado a cada render do pai. Se Component é envolto em React.memo (pra evitar re-renders quando props não mudaram), a comparação shallow dá “diferente” e o memo vira inútil.
// ruim — objeto novo a cada renderconst GridItem = React.memo(function GridItem({ options }: { options: Options }) { return <div style={{ gap: options.spacing }}>{/* ... */}</div>;});function Parent() { return <GridItem options={{ spacing: 8 }} />; // ^^^^^^^^^^^^^ // novo objeto → memo não ajuda}
Soluções.
1. Constante fora do componente (quando o valor é estático).
// em vez de passar objeto, passe campos individuais<GridItem spacing={8} />// primitivos são comparados por valor, sempre estáveis
Quando vale a pena se preocupar
Objeto inline em prop de componente leve (um <button> simples, <span>): irrelevante. React render é barato pra JSX primitivo.
Objeto inline em prop de componente pesado (tabela virtualizada, gráfico, árvore grande): crítico. É aí que memoização entrega valor real.
Fonte: [5][6]
21. Funções novas a cada render passadas pra filhos memoizados
O problema. Funções inline (onClick={() => doStuff(id)}) são criadas a cada render. Mesma lógica do objeto inline: quebra React.memo, dispara effects em filhos que recebem a função como dep.
// ruim — handleClick é função nova a cada render do Parentconst MemoButton = React.memo(function Button({ onClick, children }) { console.log('Button renderizou'); return <button onClick={onClick}>{children}</button>;});function Parent({ id }: { id: string }) { return ( <MemoButton onClick={() => doStuff(id)}> {/* ^^^^^^^^^^^^^^^^^^^^^^^^^^^ */} {/* função nova → MemoButton re-renderiza sempre */} Click </MemoButton> );}
Solução — useCallback.
function Parent({ id }: { id: string }) { const handleClick = useCallback(() => doStuff(id), [id]); return <MemoButton onClick={handleClick}>Click</MemoButton>;}
Agora handleClick só muda quando id muda. MemoButton só re-renderiza nessas ocasiões.
Quando useCallback não vale a pena.
Filho não é memoizado. Se não tem React.memo, a função estável não impede nada (filho re-renderiza com o pai de qualquer jeito).
Função não é passada a ninguém. Se é handler inline do próprio componente (<button onClick={...}>), esqueça — nada consome a “estabilidade”.
Overhead do próprio useCallback passa a ser perceptível em árvores gigantes com centenas de callbacks. Raro, mas possível.
Padrão que funciona mesmo com pai que muda muito — useRef pra handler “ambulante”.
// quando o handler muda de definição mas você quer referência estávelfunction Parent({ id, data }) { const handlerRef = useRef(() => {}); handlerRef.current = () => doStuff(id, data); // atualiza a cada render // exposto como função estável const stableHandler = useCallback(() => handlerRef.current(), []); return <MemoButton onClick={stableHandler} />;}
Padrão avançado — use com moderação. React 19 tem useEvent (estável como ref, mas sempre “vê” os valores atuais) pra esse caso.
React Compiler — memoização automática
O React Compiler (React 19+) analisa seu código e insere memoização automaticamente onde for benéfico. Em projetos onde está habilitado, useMemo/useCallback manuais tornam-se desnecessários em muitos casos.
Mas: (1) ainda é opt-in, nem todo projeto usa; (2) não cobre 100% dos casos (funções passadas pra libs externas, por exemplo); (3) entender o porquê da memoização continua sendo habilidade de senior.
Fonte: [4][5]
Cap. 6 — TypeScript e hooks
22. Higiene de TypeScript fraca
O problema. TypeScript é ferramenta de garantir invariantes em compile-time. Quando você escreve any toda hora, faz as unknown as Foo pra “convencer” o compilador, ou duplica types (“tem um User aqui e outro lá, quase iguais”), está jogando fora o benefício. Código passa a ter tipos decorativos — não catch real de bug.
Padrões que diferenciam senior.
1. Discriminated unions pra estados (em vez de flags booleanas).
// ruim — estados impossíveis representáveistype FetchState<T> = { isLoading: boolean; data: T | null; error: Error | null;};// o que é { isLoading: true, data: dados, error: erro } ? inválido mas tipado.// bom — discriminated union: exatamente um dos estadostype FetchState<T> = | { status: 'idle' } | { status: 'loading' } | { status: 'success'; data: T } | { status: 'error'; error: Error };// uso — narrowing automáticofunction render(state: FetchState<User>) { if (state.status === 'loading') return <Spinner />; if (state.status === 'error') return <Error error={state.error} />; if (state.status === 'success') return <UserView user={state.data} />; return <Idle />;}
Aqui o TypeScript sabe que dentro do if (state.status === 'success'), state.data existe e é User. Sem cast.
2. Exhaustive checks com never no switch default.
function handle(state: FetchState<User>) { switch (state.status) { case 'idle': return /* ... */; case 'loading': return /* ... */; case 'success': return /* ... */; case 'error': return /* ... */; default: // se alguém adicionar um novo estado e esquecer de tratar, // TS falha aqui em compile-time const _exhaustive: never = state; return _exhaustive; }}
3. unknown em vez de any quando o tipo é realmente incerto.
// ruim — any desliga o typecheck completamentefunction parse(json: any) { return json.user.name; // sem erro em compile-time, bomba em runtime}// bom — unknown força você a validar antes de usarfunction parse(json: unknown) { if (typeof json === 'object' && json !== null && 'user' in json) { // agora TS sabe que existe `user` em `json` }}// melhor — use uma lib de runtime validationimport { z } from 'zod';const UserSchema = z.object({ user: z.object({ name: z.string() }) });const parsed = UserSchema.parse(json); // valida + tipa
4. Type guards em vez de as.
// ruim — cast cego, pode estar erradoconst user = data as User;// bom — type guard testa de verdadefunction isUser(x: unknown): x is User { return typeof x === 'object' && x !== null && 'id' in x && 'name' in x;}if (isUser(data)) { // TS sabe que data é User aqui}
O que é "narrowing" em TypeScript?
Mecanismo do TS que restringe o tipo de uma variável dentro de um bloco, baseado em checks no código. Exemplos:
if (typeof x === 'string') → dentro, x é string.
if ('field' in obj) → dentro, obj tem field.
if (x instanceof Error) → dentro, x é Error.
Discriminated unions + check do discriminador.
Senior bom escreve código de forma que o TS narrow-se naturalmente — sem as, sem !, sem any. Cast só em fronteiras reais (ex: deserializar JSON).
Sinais de alerta em code review.
any explícito ou implícito (no-implicit-any no tsconfig ajuda a pegar).
as em mais de 1–2 pontos do arquivo.
Types duplicados (UserDTO no frontend, User no form, UserData no list) — consolide ou derive (Pick<User, 'id' | 'name'>).
! (non-null assertion) espalhado — se é sempre não-nulo, por que o tipo diz que pode ser null?
Fonte: [1]
23. Silenciar exhaustive-deps
O problema.react-hooks/exhaustive-deps é o principal linter de corretude pra hooks. Ele garante que toda variável usada dentro do effect/callback está declarada nas deps — o que evita stale values.
Quando aparece warning, o instinto ruim é silenciar:
// ruim — escondendo buguseEffect(() => { doStuff(value); // usa `value`, mas... // eslint-disable-next-line react-hooks/exhaustive-deps}, []); // ...não declarou — effect vai usar `value` congelado no momento do mount
Isso cria closure stale: value vale o que valia no primeiro render para sempre. Quando prop mudar, effect continua chamando doStuff com o valor antigo.
Soluções reais, em ordem de preferência.
1. Incluir a dep (resposta óbvia — o linter está certo).
useEffect(() => { doStuff(value);}, [value]);
2. Extrair pra useCallback (se o problema é a função no deps).
4. useRef (se o valor não deve triggar re-execução).
const valueRef = useRef(value);useEffect(() => { valueRef.current = value;});useEffect(() => { doStuff(valueRef.current); // usa valor mais recente, mas não reage a mudança}, []); // deps vazios de propósito — ref não precisa estar aqui
5. useEffectEvent (React 19.2+).
// pra lógica não-reativa que deve ver valores atuaisconst onUpdate = useEffectEvent(() => doStuff(value));useEffect(() => { onUpdate();}, []); // sem deps; onUpdate sempre vê `value` atual
eslint-disable é confissão
Toda vez que você escreve // eslint-disable-next-line react-hooks/exhaustive-deps, está admitindo: “não sei explicar corretamente pro React quando esse effect deve rodar”. Ou ignore com comentário explicando por quê (raramente válido), ou refatore.
Fonte: [1]
24. Hook quando função pura bastaria
O problema. Hook vem com regras: só pode ser chamado no topo de componente ou outro hook, não dentro de loops/if/callbacks. Se seu “hook” não usa useState/useEffect/useContext/useRef/outros hooks, não é hook — é função pura mascarada, e você paga o custo de regras sem ganhar nada.
// ruim — "hook" que não usa hooksfunction useFormatPrice(value: number, currency: string) { return new Intl.NumberFormat('pt-BR', { style: 'currency', currency, }).format(value);}// usofunction Product({ price }) { const formatted = useFormatPrice(price, 'BRL'); // só roda no topo! if (price > 100) { // não posso chamar useFormatPrice aqui ← regras de hooks } return <span>{formatted}</span>;}
Solução — função pura.
// bom — função pura, chamável em qualquer lugarfunction formatPrice(value: number, currency: string) { return new Intl.NumberFormat('pt-BR', { style: 'currency', currency, }).format(value);}function Product({ price }) { // chama onde precisar, incluindo dentro de condicional return <span>{price > 100 ? formatPrice(price, 'BRL') : 'Barato'}</span>;}
Regra de decisão.
Usa useState/useEffect/useRef/useContext/outro hook? → É hook. Nome começa com use.
Só processa entrada e retorna saída? → Função pura. Nome sem use.
Quando um “hook” sem hooks ainda vale. Nunca. Se você sente que faz sentido, provavelmente está preparando pra adicionar useMemo mais tarde — mas adicione quando precisar, não profilaticamente.
Fonte: [1]
25. Deps instáveis (objetos/funções criados no render)
O problema. Variação do #18 e #20. Se você coloca no array de deps algo que é recriado a cada render, o effect/callback/memo roda todo render — como se as deps fossem [Math.random()].
// ruim — options é objeto novo cada render → effect roda semprefunction Widget({ userId }: { userId: string }) { const options = { userId, includeAvatar: true }; // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ // novo objeto em cada render useEffect(() => { api.fetch(options); }, [options]); // ← effect roda a cada render; você queria roda só quando userId mudar}
O problema. Omitir prop/state do array de deps parece simplificar — “esse effect só precisa rodar no mount” — mas cria closure stale: o effect/callback captura os valores do momento em que foi criado, e nunca vê as atualizações.
// ruim — closure com `userId` congelado no primeiro renderfunction UserProfile({ userId }) { useEffect(() => { fetchUser(userId).then(setUser); }, []); // ← userId faltando! // quando userId mudar (nova rota, por exemplo), effect não roda de novo // — usuário continua vendo dados do primeiro userId}
Por que é traiçoeiro. O bug não aparece no desenvolvimento inicial — quando userId não muda, funciona bem. Aparece meses depois, quando alguém implementa navegação interna pra perfis de outros usuários.
Solução.
1. Confie no linter.react-hooks/exhaustive-deps pega isso. Se não ativou, ative agora.
2. Se realmente quer “só no mount, independente de props”, reconsidere. Geralmente significa que o componente não deveria re-renderizar para outro userId, mas sim remount — use key:
<UserProfile userId={userId} key={userId} />
3. useRef + efeito de sincronização (quando quer valor atual sem disparar re-execução — raro e delicado, ver #23).
Regra de três segundos
Toda vez que quiser omitir uma dep, pare 3 segundos e pergunte: “O que acontece quando essa variável muda?” Se a resposta é “nada deveria acontecer”, você provavelmente quer useRef (ou refatorar). Se a resposta é “o effect deveria rodar de novo”, declare a dep.
Fonte: [3]
Cap. 7 — Listas e keys
27. Usar index como key
O problema. Quando você renderiza uma lista, React usa key pra saber qual JSX corresponde a qual linha da lista entre renders. Isso permite preservar estado interno, animações, foco de input, etc.
Quando você usa index como key, qualquer mudança na ordem da lista (remover do meio, reordenar, filtrar) faz o React associar o JSX errado às linhas erradas.
O bug clássico. Lista com inputs internos por linha:
Lista é ['A', 'B', 'C']. Usuário digita “obs A” no input da linha A, “obs B” na linha B.
Usuário deleta a linha A. Lista vira ['B', 'C'].
O que acontece: React vê key=0 → ainda existe (antes era A, agora é B). React pensa “ah, a linha 0 continua aí, só mudou o label”. Reusa o <Row> antigo (que tinha note='obs A'), só troca o label pra 'B'.
Usuário vê: linha B com note “obs A”. Linha C com note “obs B”. Estado vazou entre linhas.
Solução — ID estável.
// bom — ID da entidadeitems.map(item => <Row key={item.id} label={item.label} />);
Agora, quando você remove o item com id=“a”, React vê key="a" sumir — desmonta aquela <Row> inteira, incluindo o state. Linhas remanescentes continuam identificadas por "b" e "c", state preservado corretamente.
E se a API não retornar ID?
Sempre tente adicionar no backend. É o lugar certo.
Se não for possível, gere ID estável no ponto de entrada do dado no sistema (assim que chega no frontend):
const withIds = data.map(item => ({ ...item, id: crypto.randomUUID() }));// guarde withIds no state — IDs persistem entre renders
Nunca gere ID no render.items.map(item => <Row key={crypto.randomUUID()} />) gera ID novo a cada render — pior que index, porque toda linha remonta toda vez.
Quando index como key é aceitável
Quando a lista é genuinamente estática e não tem state interno por item:
Lista de opções de menu que nunca muda de ordem.
Lista de labels sem interação.
Lista de ícones decorativos.
Mesmo assim, ID estável é melhor — só é indiferente quando garantidamente não vai dar problema. Como garantia “nunca” é difícil no mundo real, o hábito correto é sempre usar ID estável.
Nunca use Math.random() nem Date.now() como key. Pelo mesmo motivo de crypto.randomUUID() no render: geram valor novo a cada render, forçando desmontagem/remontagem constantes. Animações piscam, foco de input some, performance despenca.
Fonte: [1][6]
Cap. 8 — useEffect, o abismo
Regra geral.useEffect deve ser exceção, não default. Existe pra sincronizar React com sistemas externos (DOM manual, APIs de browser, sockets, listeners, libs de terceiros). Pra tudo mais — derivar dados, reagir a eventos de usuário, passar dados pra cima — há caminho melhor.
Quando useEffect é a ferramenta certa
Sincronizar com APIs do browser: IntersectionObserver, ResizeObserver, window.addEventListener.
Subscribir em stores externos: WebSocket, Firebase, Redux externo (mas useSyncExternalStore costuma ser melhor).
Efeitos colaterais depois do paint: analytics “página vista”, focar primeiro input.
Não é ferramenta certa pra: derivar dados, reagir a clique/input, notificar o pai, transformar props em state.
28. Transformar dados em Effect em vez de derivar no render
O problema. Clássico. Mesmo bug do 12. Guardar valor derivado em state, mas com a armadilha extra do effect: cria render duplo (render inicial com valor velho, effect atualiza, render de novo).
O problema. Cada effect dispara o próximo por setState — e você tem N renders sequenciais pra uma única interação. Código fica frágil (ordem de effects importa), testes ficam lentos, React precisa reconciliar árvore N vezes.
Se você tem useEffect(() => setX(...), [y]) e useEffect(() => setZ(...), [x]), pare. Isso é cascata. Mova pra um handler ou reducer.
Fonte: [2]
30. Lógica de evento dentro de Effect
O problema. Effects rodam quando as deps mudam — mas não sabem o que causou a mudança. Se você coloca um “side effect de ação” (toast, analytics, som) num effect, ele dispara em situações que não deveriam ser consideradas a “ação”:
Primeira carga da página (se a condição já é verdadeira).
Re-render por motivo não relacionado.
Hot-reload em desenvolvimento.
Strict Mode (em dev, React roda effects duas vezes propositalmente).
// ruim — toast dispara mesmo quando a página carrega com o item já no carrinhofunction Product({ product }) { useEffect(() => { if (product.isInCart) { showToast(`Added ${product.name} to cart!`); } }, [product.isInCart]);}
Usuário recarrega a página com o item já no carrinho → toast inesperado.
Solução — coloque a lógica no handler que causou a mudança.
// bom — toast só dispara quando o usuário clicafunction Product({ product }) { function handleAddToCart() { addToCart(product); showToast(`Added ${product.name} to cart!`); } return <button onClick={handleAddToCart}>Add to cart</button>;}
Regra. Effects reagem a dados; handlers reagem a ações. Se a lógica só faz sentido quando o usuário clicou/submeteu/digitou, ela pertence ao handler.
Fonte: [2][3]
31. Inicialização global dentro de Effect
O problema. Você precisa inicializar uma lib (SDK de analytics, Sentry, Mermaid, Facebook Pixel) uma vez, quando o app carrega. Cai no instinto:
Strict Mode roda 2x em dev, então initFacebookPixel() é chamado duas vezes. Se a função não é idempotente, bugs em dev.
Se o componente <App> remontar (em testes, HMR, rotas), init repete.
Roda depois do primeiro paint — auth check chega tarde, conteúdo “pisca” entre estados.
Solução 1 — module-level init (roda uma vez quando o módulo é importado).
// bom — roda uma vez, na carga do bundleif (typeof window !== 'undefined') { initFacebookPixel(); checkAuthToken();}export function App() { return /* ... */;}
Por que o typeof window !== 'undefined'?
Se o app tem SSR (Next.js, Remix), o arquivo também executa no servidor, onde window não existe. O guard evita crash em server-side. Em SPA puro (Vite + React Router client-side), não é necessário.
Solução 3 — em meta-frameworks, use o arquivo raiz. Next.js tem app/layout.tsx; Remix tem root.tsx; Vite+React Router tem main.tsx. Inits globais moram no topo do entry point.
Fonte: [2]
32. Notificar pai via Effect
O problema. Componente filho tem state interno (isOn) e precisa avisar o pai quando muda. Instinto ruim:
// ruim — effect atualiza o paifunction Toggle({ onChange }: { onChange: (v: boolean) => void }) { const [isOn, setIsOn] = useState(false); useEffect(() => { onChange(isOn); }, [isOn]); // problemas: // - dispara em mount (avisa o pai que está "false" sem usuário fazer nada) // - render do filho acontece ANTES do pai saber // - se pai re-renderizar baseado em isOn, há pass extra return <button onClick={() => setIsOn(v => !v)}>{isOn ? 'On' : 'Off'}</button>;}
Componentes controlled são mais previsíveis, testáveis, compostos. Use quando o state é relevante pro pai.
Fonte: [2]
33. Race conditions em fetch
O problema. Effects disparam fetches async. Se a dep muda rápido (usuário digitando busca), múltiplos fetches ficam em voo. A ordem de resposta não é garantida — resposta do query antigo pode chegar depois da resposta do query novo, sobrescrevendo com dados desatualizados.
// ruim — race conditionfunction SearchResults({ query }: { query: string }) { const [results, setResults] = useState<Result[]>([]); useEffect(() => { fetchResults(query).then(setResults); // usuário digita "re" → fetch A começa // usuário digita "rea" → fetch B começa // fetch B responde primeiro (rápido) → results = B // fetch A responde depois (lento) → results = A ← bug: mostra resultado do query antigo }, [query]);}
Solução 1 — flag ignore.
// bom — resposta velha é ignorada se o effect já foi "limpado"useEffect(() => { let ignore = false; fetchResults(query).then(data => { if (!ignore) setResults(data); }); return () => { ignore = true; // quando a dep mudar ou componente desmontar };}, [query]);
Solução 2 — AbortController (melhor: cancela a requisição HTTP).
34. Falta de cleanup (timers, listeners, subscriptions)
O problema. Recursos criados em effects (listeners, timers, sockets, subscriptions) persistem depois do componente desmontar se você não limpa. Consequências:
Memory leak. Closures vivas mantendo referência a props/state de componentes que deveriam ter ido embora.
Handlers disparando no vazio.resize chama setState em componente desmontado → warning do React + possível bug.
Conexões abertas. WebSocket fica ativo, consumindo banda, gerando eventos pra nada.
// timeruseEffect(() => { const id = setTimeout(() => {...}, 1000); return () => clearTimeout(id);}, []);// intervaluseEffect(() => { const id = setInterval(() => {...}, 1000); return () => clearInterval(id);}, []);// subscription (ex: store externo, Firebase)useEffect(() => { const unsubscribe = store.subscribe(callback); return unsubscribe; // já é função, só retornar}, []);// observeruseEffect(() => { const observer = new IntersectionObserver(callback); observer.observe(ref.current); return () => observer.disconnect();}, []);// WebSocketuseEffect(() => { const ws = new WebSocket(URL); return () => ws.close();}, []);
Regra simples
Se o effect abre/cria/registra algo, ele fecha/destrói/remove no cleanup. Sempre.
Fonte: [3]
35. useEffect onde useLayoutEffect deveria
O problema.useEffect roda depois do browser pintar. useLayoutEffect roda antes do pintar, sincronamente após mutações do DOM.
Se você precisa ler layout (tamanho, posição) ou escrever estilo/DOM baseado em layout, useEffect causa flicker: o browser pinta o estado “errado”, depois o effect ajusta, e pinta de novo.
Cuidado — useLayoutEffect bloqueia o paint. Código lento dentro dele faz a UI travar (usuário espera pelo ajuste antes de ver qualquer pixel). Use só pro necessário; tudo que puder ficar em useEffect, fique.
useSyncExternalStore — quando usar
Hook dedicado a subscribir em stores externos (fora do React). Resolve problemas que useEffect tem com concurrent rendering (suspense, transições). Libs modernas (Zustand, Redux) já usam por baixo — você só escreve manual se estiver integrando store próprio/legado.
Fonte: [3]
36. Loop infinito por deps mal configuradas
O problema. Effect que chama setState + deps mal configuradas = loop infinito. CPU a 100%, browser travando, console cuspindo warnings.
Causa 1 — sem array de deps.
// ruim — effect roda TODO render → setState → novo render → loopuseEffect(() => { setCount(c => c + 1);});
Sem deps, o effect roda após cada render. Effect chama setState, dispara novo render, roda de novo.
Causa 2 — objeto/função inline em deps.
// ruim — options é objeto novo a cada render → deps "mudam" sempre → loopfunction Widget({ userId }: { userId: string }) { const options = { userId }; useEffect(() => { fetch('/api', options).then(setData); }, [options]);}
options é { userId: 'abc' } no render 1, { userId: 'abc' } no render 2 — mesmo conteúdo, referência diferente. React compara com Object.is, detecta “mudou”, roda effect, que chama setData, dispara render, options é novo objeto, effect roda de novo… ver 25. Deps instáveis (objetos/funções criados no render).
Causa 3 — setState sem updater function, dep faltando.
// ruim — precisa de count na dep, mas setar count ativa o effectuseEffect(() => { setCount(count + 1);}, []); // linter avisa, você inclui count no deps... e entra em loop
Soluções.
Se o effect só deve rodar no mount: [] (vazio) e certifique-se que os valores lidos dentro não mudam (ou usar updater function).
Se o effect deve reagir a algo: declare [algo], e esse algo deve ser estável (primitivo ou memoizado).
setState com updater: setCount(c => c + 1) em vez de setCount(count + 1) — não precisa de count na dep.
Objetos em dep: memoize com useMemo, ou passe só primitivos.
Se o browser travou no dev
Provavelmente é loop de effect. Comente os useEffects recentes, destrave, diagnostique um por um.
Fonte: [3]
Cap. 9 — Data fetching, erros e estados de UI
37. Camada de data fetching caseira
O problema. “Vou só escrever um wrapper em fetch.” Seis meses depois, você tem 600 linhas de código lidando com:
Retry com backoff exponencial
Deduplicação (2 componentes pedindo o mesmo endpoint ao mesmo tempo)
Cache (não repetir fetch se dado ainda é fresco)
Revalidação (refetch quando janela volta ao foco, em intervalos, em navegação)
Cancelamento de fetch anterior quando o novo começa
Loading states, error states
Infinite scroll / paginação
Optimistic updates em mutations
Invalidação de cache em mutations
SSR / streaming
É projeto por si só, com tests, edge cases, regressões. E você vai fazer mal — porque não é o foco do produto.
Solução — use lib madura.
TanStack Query (@tanstack/react-query) — padrão de facto pra client-side.
SWR (swr) — mais leve, filosofia “stale-while-revalidate” (mostra cache enquanto revalida).
Nativo de meta-framework.
Next.js App Router: fetch server-side com cache integrado, Server Actions pra mutations.
Remix / React Router 7: loader/action pra dados e mutations.
TanStack Start: loaders + queries integrados.
Pra apps SSR modernos, preferir o mecanismo do framework — já lida com caching, streaming, hydration.
Quando fetch puro ainda serve
Scripts, protótipos, fetches únicos sem interação (fetch-and-forget). Não adicione React Query pra um fetch() que roda uma vez no mount sem reuso. Mas assim que houver 2+ consumidores do mesmo endpoint, ou necessidade de refetch, migre.
Fonte: [1][4]
38. Zero error boundaries
O problema. JavaScript é dinâmico — um undefined.map() ou null.name em qualquer componente crash o React. Sem error boundaries, o crash derruba a árvore inteira e o usuário vê tela branca. Sem telemetria, você nem fica sabendo.
Error boundaries são componentes especiais que capturam erros em renders de filhos e permitem mostrar fallback.
Error boundaries não pegam tudo. Não capturam: erros em event handlers, em effects async, em setTimeout, em código fora do React. Pra esses, try/catch tradicional + log pra telemetria.
Por que error boundaries são classes?
Historicamente, React só tinha API de componentDidCatch em classes. Hoje existe a hook useErrorBoundary em libs como react-error-boundary, mas por baixo ainda é classe (limitação do React até 19).
Fonte: [1]
39. catch {} vazio
O problema. Bloco catch sem conteúdo (ou só com console.log). O erro é engolido — não loga, não notifica o usuário, não vai pra telemetria. Quando o bug aparece em prod, não tem rastro.
Loading — skeleton (placeholder do layout final) é melhor que spinner genérico, porque sinaliza o que está vindo.
Error — mensagem humana + ação (“Tentar de novo”, “Reportar”). Nunca jogar o stack trace na cara do usuário.
Empty — visual distinto de loading. Idealmente com call-to-action (“Criar primeiro cliente”).
Success — o conteúdo.
Teste com DevTools throttling. No Chrome DevTools > Network > “Slow 3G”, você vê o loading. Em dev-local o fetch é instantâneo e você nem percebe. Teste devagar uma vez por feature.
Stale-while-revalidate
Padrão onde, em refetches subsequentes, você mostra o dado antigo enquanto atualiza em background. Evita flash de loading toda vez que o usuário navega de volta. Libs como SWR e React Query fazem isso por padrão.
Fonte: [1]
Cap. 10 — Performance e bundle
Core Web Vitals — o vocabulário do capítulo
Métricas que o Google (e usuários) cobram. Afetam SEO e percepção de qualidade.
LCP (Largest Contentful Paint): tempo até o maior elemento visível renderizar. Meta: < 2.5s.
INP (Interaction to Next Paint): tempo entre interação (click/input) e próximo paint. Meta: < 200ms.
CLS (Cumulative Layout Shift): quanto conteúdo “pula” durante carregamento. Meta: < 0.1.
FCP (First Contentful Paint): primeira renderização de qualquer conteúdo. Meta: < 1.8s.
TTFB (Time to First Byte): tempo até primeiro byte de resposta do server. Meta: < 0.8s.
Medir com PageSpeed Insights, Chrome DevTools > Lighthouse, ou web-vitals npm package em prod.
41. Sem code splitting / lazy routes
O problema. Bundle único de 2MB no primeiro load. Usuário só quer ver a página de login, mas o browser baixa código de /admin/reports + /checkout + dependências gigantes que nem são usadas ainda.
Consequências:
LCP ruim. Quanto maior o JS, mais tempo até a página ser interativa.
INP ruim. Parse + execute de JS bloqueia o main thread.
Banda/dados do usuário. Mobile em 4G pagando por código que ele não vai usar.
Solução — dividir o bundle em chunks carregados sob demanda.
Por rota (80% do ganho):
// ruim — tudo importado estaticamente, tudo no bundle inicialimport Home from './pages/Home';import Admin from './pages/Admin';import Checkout from './pages/Checkout';// bom — lazy load por rotaimport { lazy, Suspense } from 'react';const Home = lazy(() => import('./pages/Home'));const Admin = lazy(() => import('./pages/Admin'));const Checkout = lazy(() => import('./pages/Checkout'));function App() { return ( <Suspense fallback={<PageSkeleton />}> <Routes> <Route path="/" element={<Home />} /> <Route path="/admin" element={<Admin />} /> <Route path="/checkout" element={<Checkout />} /> </Routes> </Suspense> );}
Cada lazy() gera um chunk separado, baixado só quando a rota é visitada.
Por componente pesado:
// editor rich-text de 300KB — carregar só quando o usuário abre o modalconst RichTextEditor = lazy(() => import('./RichTextEditor'));function Page() { const [editing, setEditing] = useState(false); return ( <> <button onClick={() => setEditing(true)}>Editar</button> {editing && ( <Suspense fallback={<Spinner />}> <RichTextEditor /> </Suspense> )} </> );}
Ferramentas pra descobrir o que pesa.
rollup-plugin-visualizer (Vite) — árvore visual do bundle.
webpack-bundle-analyzer — equivalente pra Webpack.
Chrome DevTools > Coverage — mostra quanto % do JS baixado é realmente executado numa página.
Meta-frameworks. Next.js, Remix e similares fazem code splitting por rota automaticamente. Se você está usando, já tem o básico.
Medindo impacto
Antes: rode Lighthouse, anote LCP, tamanho do bundle inicial. Implemente splitting. Meça de novo. Se reduziu pouco, é porque o bundle não era o gargalo — foque em outra coisa.
Fonte: [4]
42. Imagens não otimizadas
O problema. PNG/JPG gigantes (1920x1080 do designer) exibidos como thumbnails de 100x100. Sem loading="lazy", o browser baixa tudo no load. Sem formato moderno, arquivos 3–5x maiores que o necessário.
Consequências: LCP alto (imagem grande bloqueia “conteúdo principal”), banda desperdiçada, bateria (mobile).
Solução — checklist.
1. Formatos modernos.
WebP: ~30% menor que JPG, suporte universal desde 2020.
AVIF: ~50% menor que JPG, suporte em ~95% dos browsers (2026).
Fallback pra JPG/PNG só pra cobrir legado extremo.
5. CDN de imagem. Em vez de servir do seu backend, use serviço que otimiza on-the-fly:
Cloudinary, Imgix, ImageKit — transformações por URL.
next/image (Next.js), @unpic/react (framework-agnóstico) — componentes que integram com CDN.
6. SVG pra ícones/logos. Texto, escalável, leve — nunca PNG pra elementos vetoriais.
Por que AVIF > WebP > JPG?
Compressão de imagem evolui por décadas. JPEG (1992) usa DCT; WebP (2010, baseado em VP8) adiciona predição; AVIF (2019, baseado em AV1) usa redes neurais + predição intra-frame avançada. Cada geração dá mais qualidade por byte.
Trade-off: AVIF codifica mais devagar no servidor. CDNs modernas resolvem cacheando.
Fonte: [4]
43. Listas longas sem virtualização
O problema. Renderizar 10.000 linhas num <table> trava o navegador. Mesmo com React.memo, são 10.000 componentes no DOM — scroll fica laggy, INP ruim, memória alta.
O insight: o usuário só vê ~20 linhas de cada vez. Renderizar 10.000 é desperdício.
Solução — virtualização. Renderiza só as linhas visíveis no viewport (+ buffer). Quando scrollar, remove as que saíram e monta as que entraram.
Scrollbar pode ficar imprecisa inicialmente se a estimativa de altura tá longe da real.
Fonte: [4]
44. Sem debounce/throttle em eventos caros
O problema. Eventos de input disparam a cada keystroke. Eventos de scroll/resize disparam a 60+ vezes por segundo. Se cada um faz fetch ou cálculo pesado, o app trava.
// ruim — fetch a cada tecla digitada<input onChange={e => fetchResults(e.target.value)} />// digita "react" → 5 fetches (r, re, rea, reac, react)
Soluções.
Debounce — espera N ms sem novas chamadas antes de executar. Ideal pra inputs (busca, autocomplete).
import { useDebouncedCallback } from 'use-debounce';function Search() { const debouncedFetch = useDebouncedCallback((q: string) => { fetchResults(q); }, 300); return <input onChange={e => debouncedFetch(e.target.value)} />; // usuário digita "react" em 300ms — 1 fetch no final, com "react"}
Throttle — garante no máximo N chamadas por segundo. Ideal pra scroll/resize.
Input de busca: 250–400ms (sensação de “estou digitando”).
Input normal (validação em tempo real): 100–200ms.
Scroll handler: 16ms (60fps) ou 100ms (aceitável).
Resize: 100–250ms (nunca faz sentido reagir a cada pixel).
Debounce vs Throttle — diferença mental
Debounce: “só execute se ficar quieto por N ms”. Usuário digita 10 letras em rajada → 1 execução, depois do último keystroke.
Throttle: “no máximo 1 execução por N ms”. Scroll dispara 1000 eventos → executa 1x a cada 100ms, ignora os entre.
Debounce pra “quero valor final”. Throttle pra “quero amostra regular”.
Cuidado com useMemo(() => debounce(...), [...]). Criar novo debounce a cada render quebra tudo. Use useCallback pra estabilizar, ou hooks de lib como useDebouncedCallback.
Fonte: [4]
45. Computação CPU-heavy no main thread
O problema. JavaScript no browser roda em um único thread (o main thread) — o mesmo que renderiza UI, processa cliques, aplica layouts. Se você pôr algo pesado (parse de 10MB de JSON, geração de PDF, processamento de imagem, algoritmo de grafo) nesse thread, tudo congela até terminar.
Sintomas: scroll trava, cliques não respondem, animação pula, INP arruinado.
Solução — Web Workers. Threads de verdade, separados do main. Executam JavaScript em paralelo, trocam dados por mensagens.
// Component.tsximport { wrap } from 'comlink';import AnalyzerWorker from './worker?worker';const analyzer = wrap(new AnalyzerWorker());async function process(data) { const result = await analyzer.analyze(data); // parece chamada normal setState(result);}
Quando vale Web Worker.
Operação CPU-heavy que ultrapassa ~50ms (bloqueio perceptível).
Exemplos reais: parse de JSON grande, geração de PDF no cliente (jsPDF), image processing (canvas manipulation), crypto em volume (hashing, encryption), árvore de decisão grande (big tree traversal).
Quando não vale.
Setup do worker tem custo (~10ms). Pra operações < 50ms, não compensa.
Comunicação main ↔ worker serializa dados (postMessage) — transferir 100MB vai demorar também. Transferable objects (ArrayBuffer) ajudam.
Long tasks e INP
“Long task” no browser = operação > 50ms no main thread. Cada uma bloqueia UI por aquele tempo. INP mede o pior caso. Meta pra INP: < 200ms. Se uma função sua tem > 50ms de execução, pense em Worker ou requestIdleCallback.
Fonte: [4]
46. CLS alto por falta de reserva de espaço
O problema. Conteúdo “pula” durante carregamento:
Imagem carrega → conteúdo abaixo é empurrado.
Anúncio aparece → layout shift.
Fonte custom carrega e troca métrica → texto reflui.
Embed de Twitter/YouTube carrega → espaço muda.
CLS alto não é só estética — Google penaliza em SEO, e usuário clica no botão errado porque pulou do lugar.
Solução — reserve espaço antes do carregamento.
1. width/height em <img> (crítico).
<!-- ruim — browser não sabe tamanho até baixar --><img src="hero.jpg" alt="..." /><!-- bom — browser reserva aspect ratio desde o início --><img src="hero.jpg" alt="..." width="1200" height="600" />
Com width e height, browser calcula aspect-ratio e reserva o espaço mesmo antes da imagem chegar.
4. Skeletons de tamanho fixo. Skeletons (loading placeholders) devem ocupar exatamente o mesmo espaço que o conteúdo final. Se o card real tem 200px de altura, skeleton tem 200px.
5. Ads/embeds em container de tamanho reservado.
<div style="min-height: 250px"><!-- ad slot 300x250 --></div>
Diagnóstico. Chrome DevTools > Performance > grava carregamento da página > painel “Layout Shifts” lista o que pulou.
CLS — o que conta
Fórmula: impact fraction × distance fraction. Shift pequeno num pedacinho da tela = CLS baixo; shift grande movendo conteúdo central = CLS alto.
Shifts causados por interação do usuário nos últimos 500ms (ex: clique abre accordion) não contam — são “esperados”. Shifts automáticos durante carregamento contam.
Fonte: [4]
Cap. 11 — Acessibilidade
A11y não é feature — é qualidade mínima
Um app que não é acessível é um app quebrado pra 15–20% dos usuários, além de risco jurídico em várias jurisdições (ADA nos EUA, EAA na UE a partir de 2025, LBI no Brasil).
Tecnologias assistivas que você precisa conhecer.
Screen reader: lê a tela em voz alta. Principais: NVDA (Windows, grátis), JAWS (Windows, pago), VoiceOver (Mac/iOS, nativo), TalkBack (Android).
Navegação por teclado: Tab pra navegar, Shift+Tab pra voltar, Enter/Space pra ativar, Esc pra fechar.
Zoom: usuários com baixa visão aumentam fonte até 200%+.
Alto contraste / dark mode: preferências prefers-contrast, prefers-color-scheme.
Reduced motion: prefers-reduced-motion — desativa animações pra quem sente enjôo.
Teste mínimo: feche os olhos e navegue o app só com teclado + screen reader. Se não funcionar, está quebrado.
47. <div onClick> em vez de <button>
O problema. Desenvolvedores usam <div> com onClick porque “é mais fácil de estilizar”. Mas <div>não é semanticamente um botão — e tecnologia assistiva trata como texto qualquer:
Não é focável via Tab (a menos que você adicione tabIndex={0}).
Não ativa com Enter ou Space (a menos que você implemente manualmente).
Screen reader não anuncia “button” — lê só o texto.
Não expõe estados (disabled, pressed) sem atributos ARIA.
Tentar “ressemantizar” um <div> é recriar mal o que <button> já faz:
// ruim — só o olho de humano vê como botão<div onClick={handleClick} className="btn">Salvar</div>// ainda ruim — dando um jeito; agora falta estado de disabled, active, etc<div role="button" tabIndex={0} onClick={handleClick} onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') handleClick(); }} aria-label="Salvar"> Salvar</div>// bom — elemento certo pro trabalho<button type="button" onClick={handleClick}>Salvar</button>
<button> já tem:
role="button" implícito.
Foco via Tab.
Ativação via Enter e Space.
Estado :focus-visible para estilizar foco.
Atributo disabled semântico.
Eventos de teclado consistentes com convenções do SO.
“Mas o estilo default é feio.” Resete com CSS, não abandone o elemento:
button.unstyled { all: unset; /* ou reset manual */ cursor: pointer; /* aplicar estilos do design system */}
Regra simples.
Ação na mesma página (toggle, abrir modal, submeter form) → <button>.
Navegação pra outra URL → <a href="...">.
Toggle de estado com 2 opções → <button aria-pressed="..."> ou <input type="checkbox">.
Opção dentro de grupo → <input type="radio">.
Linter te protege
eslint-plugin-jsx-a11y tem regra click-events-have-key-events — avisa quando você coloca onClick em <div> sem onKeyDown.
Fonte: [5][6]
48. Imagens sem alt
O problema.<img> sem atributo alt deixa screen readers confusos:
Sem alt: screen reader fala o nome do arquivo ("logo-final-v3-retina.png") ou pula — ambos ruins.
alt="": explicitamente marca como decorativa — screen reader pula, correto para imagens puramente estéticas.
// decorativa — pula no screen reader<img src="/flourish.svg" alt="" />// informativa — texto descreve a INFORMAÇÃO, não a aparência<img src="/chart.png" alt="Vendas cresceram 40% no Q3 2025" />// logo como marca da empresa<img src="/logo.svg" alt="Acme Inc." />// link com ícone<a href="/settings"> <img src="/gear.svg" alt="Configurações" /></a>// link com texto + ícone — ícone é decorativo<a href="/settings"> <img src="/gear.svg" alt="" /> Configurações</a>
Regras gerais.
Nunca repita texto visível no alt. Se o botão já tem texto “Salvar”, não coloque alt="Salvar" no ícone.
Alt não precisa dizer “imagem de…“. Screen reader já anuncia “imagem” automaticamente. Escreva só o conteúdo: alt="Gráfico de barras...".
Para gráficos complexos, alt curto + descrição longa em elemento separado (<figcaption> ou aria-describedby).
Fotos de pessoas: descreva o relevante (alt="João Silva, CEO da Acme"), não traços irrelevantes.
Fonte: [5][6]
49. Forms sem <label> associado
O problema.<input> sem <label> é um campo mudo pra screen reader. Usuário ouve “edit text, in” e tem que adivinhar o que é.
// ruim — placeholder não é label<input type="email" placeholder="E-mail" />// ruim — label existe, mas não está associado ao input<label>E-mail</label><input type="email" />// bom — htmlFor liga ao id do input<label htmlFor="email">E-mail</label><input id="email" type="email" />// bom também — label envolve o input<label> E-mail <input type="email" /></label>
Por que placeholder não substitui label.
Desaparece quando o usuário digita — usuário esquece o que era, tem que apagar pra ver de novo.
Contraste fraco — muitos designs fazem placeholder cinza claro, difícil de ler.
Screen reader comporta diferente — nem todos leem placeholder.
Usuários com déficits cognitivos perdem contexto quando o rótulo some.
Quando label visível não cabe no design — use aria-label.
// campo de busca sem label visível, mas acessível<input type="search" aria-label="Buscar produtos" placeholder="Buscar..." />
Ou aria-labelledby apontando pra outro elemento:
<h2 id="form-title">Edite seu perfil</h2><input type="text" aria-labelledby="form-title" />
aria-label — rótulo invisível (quando não há texto visível).
aria-labelledby — id de outro elemento que rotula este.
aria-describedby — id de texto de ajuda/erro.
aria-invalid — marca campo com erro.
aria-required — indica obrigatório (redundante se já tem required).
role="alert" — anuncia mudança (como mensagem de erro aparecendo).
Fonte: [5][6]
50. Focus management ignorado em SPAs / modais
O problema. Em apps tradicionais (multi-page), cada navegação é page load — browser reseta foco pra top, screen reader anuncia o novo <title>. Em SPA, você só troca a URL e renderiza outra árvore — foco permanece onde estava.
Sintomas:
Navegação SPA: screen reader não anuncia que chegou em outra página. Foco fica no link clicado, que agora nem existe mais.
Modal abre: foco continua no botão que abriu. Usuário de teclado não está “dentro” do modal — tabs vão pro fundo da página atrás.
Modal fecha: foco vai pra document.body (ou some). Usuário se perde.
Dropdown/menu/popover: mesmo problema.
Soluções.
1. Anunciar mudança de rota em SPAs.
// focar o h1 principal quando a rota mudarfunction RouteAnnouncer() { const location = useLocation(); const h1Ref = useRef<HTMLHeadingElement>(null); useEffect(() => { h1Ref.current?.focus(); }, [location.pathname]); return null; // ou componente wrapping o h1}
2. Modal — focus trap + devolver foco ao trigger.
// ruim — modal sem gestão de focofunction Modal({ open, onClose, children }) { if (!open) return null; return createPortal(<div className="modal">{children}</div>, document.body);}// bom — foco move pro modal ao abrir, trava dentro, devolve ao fecharimport { Dialog } from '@radix-ui/react-dialog';function EditModal() { return ( <Dialog.Root> <Dialog.Trigger>Editar</Dialog.Trigger> <Dialog.Portal> <Dialog.Overlay /> <Dialog.Content> <Dialog.Title>Editar perfil</Dialog.Title> {/* Radix cuida de: focus move, focus trap, Esc, click outside, return focus */} </Dialog.Content> </Dialog.Portal> </Dialog.Root> );}
3. Dropdowns / menus / comboboxes — mesma lógica. Use Radix UI, React Aria, Headless UI — libs que fazem a a11y direito. Não reimplemente.
O que é "focus trap"?
Mecanismo que impede o foco de escapar de um contêiner até ele ser fechado. Quando modal está aberto, Tab circula entre elementos focáveis do modal; quando chega no último, volta pro primeiro. Essencial pra usuário de teclado não “sair” do modal sem querer.
Cuidado — não use autoFocus cegamente. Auto-focar um input ao montar funciona em alguns casos (campo principal de form) mas atrapalha em outros (page load com usuário rolando o header). Pense caso a caso.
Checklist rápido de a11y.
Navegação completa só com Tab/Shift+Tab/Enter/Esc?
Foco visível (outline/ring) em todos os elementos interativos?
Modal: foco move ao abrir, trava dentro, devolve ao fechar?
Pra entender o que é renderizado numa condição específica, o leitor precisa andar até o final do ternário, contando parênteses. Debug por bisect — não por leitura.
Solução 1 — early returns.
// legível — uma condição por linha, chegou ao fim = sucessofunction List({ isLoading, error, data }) { if (isLoading) return <Spinner />; if (error) return <Error error={error} />; if (!data || data.length === 0) return <Empty />; return <List data={data} />;}
Cada condição se explica, não há aninhamento, alteração futura não precisa se preocupar com a estrutura do ternário.
Solução 2 — variáveis extraídas quando o condicional é pequeno.
O problema. Código escrito em cascata de if/else aninhados quando inverter a condição e retornar cedo resolveria em 1 nível:
// ruim — pirâmide de ifsfunction process(user) { if (user) { if (user.active) { if (user.role === 'admin') { // lógica principal return doAdminStuff(user); } else { return 'não autorizado'; } } else { return 'inativo'; } } else { return 'sem usuário'; }}// bom — guard clausesfunction process(user) { if (!user) return 'sem usuário'; if (!user.active) return 'inativo'; if (user.role !== 'admin') return 'não autorizado'; return doAdminStuff(user);}
Benefícios do early return.
Uma condição por vez. Leitor não precisa manter 4 ifs abertos na cabeça.
Intenção explícita. “Se não passa nesse check, não continue” é mais direto que “se passa nesse check, aninhe tudo que vem depois”.
Diff pequeno. Adicionar um novo guard é +1 linha, não reformatar pirâmide.
Função “principal” visível. O happy path (última linha, sem aninhamento) é o foco.
Regra prática
Se a função tem if/else/if/else com mais de 2 níveis, inverta condições e retorne cedo. “Linked list of returns” > “binary tree of ifs”.
Exceção. Em render de componentes com JSX, às vezes uma condição pequena inline é melhor que sair com early return (evita múltiplos <> fragmentos confusos). Use bom senso — o objetivo é legibilidade, não regra cega.
Fonte: [1]
53. Magic numbers
O problema. Números literais no código sem explicação do que significam. Leitor precisa caçar contexto, adivinhar, perguntar no Slack.
// ruim — o que é cada número?if (retries > 3) throw new Error('fail');setTimeout(pollStatus, 86400000);if (user.age < 13) showParentalConsent();const discount = total * 0.15;
Seis meses depois, alguém precisa trocar 3 por 5 — mas grep por "3" retorna milhares de matches. Alguém muda 86400000 pra 86_400_000 (um dia em ms, mas escrito menos confuso) e quebra se outro código esperava o valor exato.
No topo do módulo — se usadas em vários pontos do mesmo arquivo.
Arquivo dedicado (constants.ts por domínio) — se usadas em vários lugares.
Config externo — se devem mudar sem deploy (env vars, feature flags).
Quando número literal é ok.
0, 1, -1, 2 — valores tão universais que não precisam de nome (arr[0], x * 2, i + 1).
Valores explicados pelo contexto imediato: <Grid columns={3} /> — “3 colunas” já é óbvio.
Matemática direta com significado universal: angle * Math.PI / 180 (radianos), 1024 * 1024 (1 MiB — aqui ainda vale uma const).
Regra mental. Se você tem que explicar o número em comentário, ele merece um nome em vez do comentário. const MAX_RETRIES = 3 é auto-documentado.
Por que "magic"?
Termo clássico de programação: número que “magicamente” tem significado que só quem escreveu conhece. A correção é desmagicar — dar nome ao significado.
Fonte: [1]
Cap. 13 — Arquitetura e manutenibilidade
54. Mesma lógica condicional espalhada
O problema. Regra de negócio duplicada em 15 lugares. Quando a regra muda (e vai mudar), você precisa caçar todas as ocorrências. Esquecer uma = bug sutil em produção.
// espalhado pela codebase// ProfilePage.tsx: if (user.role === 'admin' || user.role === 'superadmin') showEditButton();// Sidebar.tsx: if (user.role === 'admin' || user.role === 'superadmin') showAdminLink();// UserList.tsx: if (user.role === 'admin' || user.role === 'superadmin') showDeleteAction();// ... +12 arquivos
No dia que o produto decide que “editor” também pode editar, você faz grep por role === 'admin', acha 15 lugares, muda cada um, quer Deus que não tenha esquecido de nenhum. Test coverage não cobre todos os caminhos. Bug aparece semanas depois.
Solução — centralize regras de negócio em funções nomeadas.
Testável isoladamente. Teste unitário na função de permissão, não em 15 componentes.
Consistência. Impossível dois lugares implementarem a regra de forma sutilmente diferente.
Quando criar o helper.
2ª ocorrência: considerar. A terceira ocorrência te machucou.
3ª ocorrência: extrair, sem dúvida.
1ª ocorrência: não antecipe. DRY tem custo (abstração prematura).
"Regra de negócio" vs "regra de apresentação"
Regra de negócio: “admin pode editar” — fato sobre o domínio, muda quando o produto muda.
Regra de apresentação: “mostrar botão em vermelho” — fato sobre UI, muda quando design muda.
As de negócio são as que você deve centralizar com mais rigor. As de apresentação podem morar junto do componente sem drama.
Fonte: [1]
55. Sem abstração sobre libs third-party
O problema. Você escolhe uma lib de notifications (react-toastify) e importa ela direto em 80 componentes. Um ano depois, você quer trocar por sonner (mais leve, melhor UX). Resultado: refactor de mês, tocando 80 arquivos, cada commit uma revisão.
O mesmo acontece com:
Logger (winston → pino → axiom)
Analytics (mixpanel → posthog)
Date lib (ver [[#2. Deps pesadas quando existem alternativas leves|#2]])
HTTP client (axios → fetch)
Modal lib (react-modal → radix-ui)
Solução — wrap atrás de interface interna.
// src/lib/notifications.ts — único lugar que sabe que é react-toastifyimport { toast } from 'react-toastify';export const notify = { success: (msg: string) => toast.success(msg), error: (msg: string) => toast.error(msg), info: (msg: string) => toast.info(msg), dismiss: () => toast.dismiss(),};
// em todo o app — acopla só à sua interfaceimport { notify } from '@/lib/notifications';notify.success('Salvo!');
Trocar de lib agora é: editar src/lib/notifications.ts pra usar sonner. Um arquivo, 80 componentes continuam funcionando.
Princípio — criar ponto de controle único. Esse padrão é conhecido como anti-corruption layer em DDD: você protege seu código de peculiaridades da lib externa.
Cuidado — overhead de abstração. Nem toda lib merece wrapper.
Vale quando a lib é usada em muitos lugares + é plausível trocar + a API é não-trivial.
Não vale quando a lib é tão estabelecida que trocar nunca vai acontecer (React, TypeScript, Node built-ins). Wrap em cima do wrap em cima do wrap é anti-pattern chamado “lasanha arquitetural”.
Heurística. Pergunte: “Se amanhã eu precisasse trocar essa lib, seria trivial, médio ou caro?” Se “caro”, wrap. Se “trivial”, não.
Abstração vs acoplamento
Toda abstração adiciona camada. A camada tem custo (uma indireção a mais na leitura, um lugar a mais pra procurar bug). Só compensa quando protege de algo real. Abstrair console.log atrás de um logger.debug é overkill se você nunca vai trocar console.
Fonte: [1]
56. Arquivos gigantes que ninguém ousa refatorar
O problema.LegacyDashboard.tsx tem 3000 linhas. Sem testes. Lógica crítica. Cada um na equipe tem medo de tocar. Ele cresce mais um pouco a cada sprint, porque é o caminho de menor resistência (“o que estou mexendo já tá aqui, só vou adicionar mais 100 linhas”).
Com o tempo, o arquivo vira dívida técnica intransponível: pra “reescrever” precisa de sprint dedicado, produto não prioriza, ninguém se voluntaria, então vai piorando.
Estratégia incremental — a única que funciona.
1. Characterization tests primeiro.
Antes de tocar uma linha, capture o comportamento atual (inclusive bugs conhecidos) com testes. Isso vira sua rede de segurança.
// não importa se o código é feio — testa o que ELE FAZ hojetest('LegacyDashboard renderiza tabela com dados filtrados por role', () => { const users = [...]; render(<LegacyDashboard users={users} filter="admin" />); expect(screen.getAllByRole('row')).toHaveLength(3);});test('LegacyDashboard ignora filtro quando há erro — bug conhecido mas aceito', () => { // documenta o comportamento real, inclusive o errado});
2. Extraia uma função pura de cada vez.
Pega um trecho de lógica (filtro, ordenação, formatação). Tira pra função pura fora do componente. Testa a função isolada. Substitui o trecho no legacy.
// antes — inline no JSX{users .filter(u => u.active && u.role === filter) .sort((a, b) => a.name.localeCompare(b.name)) .map(u => /* ... */)}// depois — função extraída + testadaconst visibleUsers = getVisibleUsers(users, filter);{visibleUsers.map(u => /* ... */)}// src/lib/users.tsexport function getVisibleUsers(users: User[], filter: Role) { /* ... */ }
3. Extraia sub-componentes pequenos.
Tabela, modal, form — cada um pra componente próprio. Testes de snapshot/interaction pra cada um.
4. Repita até o arquivo raiz ser razoável.
Em 2–3 meses de trabalho incremental (alguns PRs por semana), um arquivo de 3000 linhas vira um de 300 + 10 arquivos pequenos. Cada PR é pequeno, revisável, reversível.
Anti-pattern — “vou reescrever do zero em uma PR”.
Leva 3 vezes mais tempo do que estimou.
Diverge do mainline, merge conflicts infernais.
Ninguém revisa direito (PR de 5000 linhas).
No dia que subir, regressões que ninguém capturou.
Michael Feathers, Working Effectively with Legacy Code
“Legacy code is simply code without tests.”
A essência: o arquivo gigante é medonho porque você não tem certeza do que ele faz. Resolva a incerteza primeiro (characterization tests), aí o refactor vira mecânico.
Characterization test vs TDD
TDD (test-driven development): escreve teste antes do código; teste define o que deveria fazer.
Characterization test: escreve teste depois do código, para o que ele já faz (inclusive possíveis bugs). Objetivo: ter rede de segurança pra refatorar sem quebrar.
Ambos são rede de segurança. TDD pra código novo, characterization pra legado.
Fonte: [1]
Checklist para code review
Dependências e tooling
Deps novas justificadas (bundle size checado)?
Linter/formatter rodando em CI?
Organização
Estrutura consistente (feature-based ou domain-based)?
Arquivos da feature colocalizados?
Sem export * em barrels?
Componentes
Sem god components (> ~300 linhas)?
Sem componente definido dentro de componente?
Props mínimas (não passar objetos gigantes)?
Prop drilling sob controle (< 3 níveis)?
Estado
Nada de state pra valor derivável?
useRef pra valores que não afetam render?
Context split por domínio, value memoizado?
Reset de state via key, não via effect?
Memoização
Defaults definidos fora do componente?
Objetos/arrays inline evitados em props críticas?
useCallback em handlers passados pra filhos memoizados?
TypeScript
Zero any?
Discriminated unions em estados assíncronos?
exhaustive-deps respeitado?
Listas
Keys estáveis (nunca index)?
useEffect
Effect sincroniza com sistema externo (não deriva dados)?
Sem encadeamento de effects?
Lógica de evento nos handlers, não em effects?
Cleanup em timers, listeners, fetches?
Race conditions em fetch tratadas (flag/AbortController)?
Escolha correta entre useEffect/useLayoutEffect?
Data fetching e erros
Usando lib de data fetching (React Query/SWR/framework)?
Error boundary em seções críticas?
Sem catch {} vazio?
Loading + error + empty states cobertos?
Performance
Code splitting por rota?
Imagens otimizadas (WebP/AVIF, lazy, responsive)?
Listas longas virtualizadas?
Debounce/throttle em eventos caros?
CPU-heavy em Web Worker?
CLS controlado (espaço reservado)?
Acessibilidade
<button> pra clicável (não <div>)?
alt em todas as imagens?
<label> em todo input?
Focus management em navegação SPA e modais?
Legibilidade
Early returns em condicionais complexas?
Magic numbers nomeados?
Arquitetura
Regras de negócio centralizadas (sem duplicação)?
Libs third-party encapsuladas em wrapper?
Bibliografia
Todas as fontes usadas na consolidação deste manual. Numeração citada no final de cada item.
React Docs (oficial) — You Might Not Need an Effect. Referência autoritativa do capítulo 8; origem dos itens sobre derivar dados no render, key-based reset, encadeamento de effects e race conditions.