Quando você cria um formulário com dezenas de campos, cada digitação do usuário não deveria custar mais do que alguns microssegundos de trabalho do JavaScript mas na maioria das bibliotecas de formulário, uma única tecla pressionada causa uma cascata de re-renders que percorre toda a árvore React — do componente raiz até os filhos.
Esse artigo documenta as otimizações que fizemos no Schepta para eliminar esse problema com algumas mudanças arquiteturais que mudam fundamentalmente onde o estado vive e quem é notificado quando ele muda.
O problema: onde o estado mora importa
Num formulário típico construído com useState, o estado de todos os campos vive num único objeto no topo da árvore:
FormFactory (state: { nome, email, telefone, ... })
└── ScheptaFormProvider (context: { values, adapter })
└── Context.Provider ← emite novo valor a cada keystroke
└── Seção A
└── Campo "nome" ← re-renderiza (faz sentido)
└── Campo "email" ← re-renderiza (não precisa)
└── Seção B
└── Campo "telefone" ← re-renderiza (não precisa)
O React Context não é granular. Quando qualquer valor no contexto muda, todos os consumidores daquele contexto re-renderizam (mesmo que o campo específico não tenha mudado).
A solução em três camadas
Camada 1 — O adapter não depende mais do React para escrever
Antes, quando o usuário digitava algo, o NativeReactFormAdapter chamava this.setState — o setter do useState do FormFactory. Isso colocava o React no caminho crítico de toda escrita:
setValue("nome", "João")
→ this.setState({ nome: "João", ... }) ← React setState
→ FormFactory re-renderiza
→ ScheptaFormProvider re-renderiza
→ Context.Provider emite novo valor
→ TODOS os campos re-renderizam
A mudança: o adapter agora é self-contained. O estado vive em this.state (JS puro), e as escritas notificam diretamente os subscribers via um sistema de pub/sub interno, sem passar pelo React:
setValue(field: string, value: any): void {
// Atualiza estado interno (JS síncrono, sem React)
this.state = { ...this.state, [field]: value };
this._version++;
// Notifica só quem precisa saber
this._fieldSubscribers.get(field)?.forEach(cb => cb());
this._globalSubscribers.forEach(cb => cb());
}
O adapter ganhou também um método setValues para resets e hidratação externa, que notifica todos os subscribers de uma vez.
Camada 2 — O contexto React se torna estável
Com o adapter self-contained, não há mais razão para passar values pelo contexto. O contexto agora carrega apenas uma coisa: a referência estável do adapter. E essa referência nunca muda após o mount.
// Antes: contexto mudava a cada keystroke
const contextValue = useMemo(() => ({ adapter, values }), [adapter, values]);
// Depois: contexto nunca muda
const contextValue = useMemo(() => ({ adapter }), []); // deps vazias
Com isso, useContext(ScheptaFormContext) deixa de disparar re-renders. O React não tem mais nada novo para propagar.
Para que os campos ainda recebam seus valores de forma reativa, usamos useSyncExternalStore — que conecta diretamente ao pub/sub do adapter, sem passar pelo contexto:
export function useScheptaFieldValue(field: string): any {
const { adapter } = useContext(ScheptaFormContext); // stable, never re-renders
const subscribe = useCallback(
(onStoreChange: () => void) => adapter.subscribeField(field, onStoreChange),
[adapter, field]
);
const getSnapshot = useCallback(
() => adapter.getFieldSnapshot(field),
[adapter, field]
);
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}
useSyncExternalStore é a API do React 18 projetada exatamente para esse padrão: stores externos que gerenciam seu próprio estado. Ela garante que só o campo cujo valor mudou re-renderiza, e nenhum outro.
Camada 3 — O FormFactory só re-renderiza quando precisa
Num formulário sem templates dinâmicos, o FormFactory não precisa saber nada sobre o que o usuário está digitando. Ele renderizou a estrutura do formulário uma vez e seu trabalho está feito.
Mas o Schepta suporta expressões como {{ $formValues.tipo === 'pj' }} para mostrar/esconder campos condicionalmente. Nesses casos, o FormFactory precisa re-rodar o orchestrator quando os valores mudam.
A solução: detectar isso em tempo de configuração, não em tempo de execução.
// Checagem estática feita uma vez por schema (useMemo([schema]))
const needsFormValueRerender = useMemo(
() => hasFormValueTemplates(schema),
[schema]
);
hasFormValueTemplates percorre o schema recursivamente e retorna true se encontrar qualquer {{ ... }} que referencie $formValues. Para formulários que só usam $externalContext ou não têm templates, retorna false.
Com isso, o FormFactory só se inscreve no adapter se o schema realmente precisar:
const subscribeToAdapter = useCallback(
(onStoreChange: () => void) => {
if (needsFormValueRerender && adapter instanceof NativeReactFormAdapter) {
return adapter.subscribeAll(onStoreChange);
}
return () => {}; // no-op para formulários estáticos
},
[needsFormValueRerender, adapter]
);
O resultado no Profiler
Antes
Ao digitar em qualquer campo, o React Profiler mostrava:
-
FormFactorycomo origem do update -
ScheptaFormProviderre-renderizando por consequência -
Context.Provideremitindo novo valor - Todos os
DefaultFieldRendererna árvore re-renderizando
Depois
O mesmo keystroke no Profiler mostra apenas:
- O
DefaultFieldRendererespecífico daquele campo re-renderizando -
FormFactorynão aparece mais como origem de updates - Todos os outros campos permanecem cinza (não re-renderizaram)
Resumo das mudanças
| Antes | Depois |
|---|---|
setValue → setState React → FormFactory re-renderiza |
setValue → this.state JS → só o campo re-renderiza |
Contexto carrega { adapter, values } — muda a cada keystroke |
Contexto carrega { adapter } — nunca muda |
Todos os useContext() re-renderizam quando qualquer valor muda |
useSyncExternalStore isola cada campo individualmente |
| FormFactory sempre re-renderiza em formulários com valores | FormFactory re-renderiza somente se o schema usa {{ $formValues.* }}
|
React.memo ineficaz (context cascade sobrepõe o memo) |
React.memo funciona como esperado — compara props, não cascata |
O que não mudou
A API pública do Schepta permanece idêntica. FormFactory, ScheptaFormProvider, useScheptaFieldValue, useScheptaFormValues — tudo funciona exatamente como antes. A otimização é transparente para quem usa a biblioteca.
Formulários com templates dinâmicos como {{ $formValues.tipo === 'pj' }} continuam funcionando corretamente: o FormFactory detecta automaticamente que precisa acompanhar as mudanças de valor e se inscreve no adapter de forma seletiva.


Top comments (0)