DEV Community

jreeeedd
jreeeedd

Posted on

Otimizando a perfomance do Schepta com React

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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());
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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]
);
Enter fullscreen mode Exit fullscreen mode

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]
);
Enter fullscreen mode Exit fullscreen mode

O resultado no Profiler

Antes

Ao digitar em qualquer campo, o React Profiler mostrava:

  • FormFactory como origem do update
  • ScheptaFormProvider re-renderizando por consequência
  • Context.Provider emitindo novo valor
  • Todos os DefaultFieldRenderer na árvore re-renderizando

Depois

O mesmo keystroke no Profiler mostra apenas:

  • O DefaultFieldRenderer específico daquele campo re-renderizando
  • FormFactory não aparece mais como origem de updates
  • Todos os outros campos permanecem cinza (não re-renderizaram)


Resumo das mudanças

Antes Depois
setValuesetState React → FormFactory re-renderiza setValuethis.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)