Re-renders em larga escala: auditando o desperdício de CPU em dashboards react

Por Marcelo Jean de Almeida Pena Especialista em Desenvolvimento e Ecossistema Web

Re-renders em larga escala: Dashboards complexos podem drenar a bateria de um notebook rapidamente devido ao processamento constante de DOM virtual que não gera mudanças visuais. Você pode estar renderizando 100 vezes por segundo sem que o usuário perceba.

O custo invisível de um dashboard que “funciona”

Seu dashboard está “funcionando”. Os dados aparecem, os gráficos renderizam, as animações são suaves. Os usuários não reclamam — pelo menos, não abertamente.

Mas há algo acontecendo nos bastidores sobre o qual ninguém fala sobre.

Abra o React Profiler. Ative a gravação. Deixe rodar por 10 segundos. Então olhe para o número de renders.

Se você está usando Context API sem otimização, você pode estar vendo 50, 100, até 500 renders completos por segundo. Cada render toca em dezenas de componentes. Cada toque recalcula funções, comparações de objetos, travessias de árvores.

E a maioria desses renders? Não muda nada visualmente.

É aqui que a falha acontece. A diferença entre um dashboard que funciona e um que realmente funciona não é um novo framework. É entender exatamente onde a CPU está sendo desperdiçada e aplicar a cirurgia certa no lugar certo.

Este artigo não é sobre “otimização de React em geral”. É sobre diagnosticar exatamente o que está acontecendo em seu dashboard específico, medir o desperdício em termos que importam (bateria, latência, CPU), e implementar as soluções mais apropriadas para seu padrão de uso.

Onde o desperdício começa: > Antes mesmo do React processar o estado, os dados precisam chegar. Se sua estratégia de rede está mal configurada, você já começa perdendo. Entenda como a latência de navegação é afetada pelo seu DNS.

O problema invisível: por que dashboards vazam CPU?

O ciclo de desperdício típico

Imagine um dashboard simples com este padrão:

// A estrutura típica do problema
function Dashboard() {
  // Um Context que armazena o estado de TUDO
  const { user, filters, data, charts, settings } = 
    useContext(AppContext);

  return (
    <>
      <Header user={user} settings={settings} />
      <Sidebar filters={filters} />
      <MainContent data={data} charts={charts} />
      <Footer />
    </>
  );
}

Isso parece inocente. Mas agora vamos ver o que realmente acontece:

  1. Um valor dentro do Context muda (qualquer um: user, filters, data, charts, settings)
  2. O React invalidata o componente Dashboard inteiro
  3. Cada consumer de AppContext em toda a árvore é marcado para re-render, não importa se ele usa aquele valor específico
  4. Componentes filhos desnecessariamente renderizam, mesmo com props identicamente iguais
  5. Se o componente pai não usa memoizationcada função inline é recriada, invalidando otimizações em netos
  6. Se há lógica custosa no render (cálculos, transformações), tudo roda novamente

Agora imagine isso acontecendo a cada 500ms (porque há um poller de dados, ou websocket updates, ou um timer). Em 10 segundos, você tem 20 ciclos completos de render de toda a árvore.

Se cada árvore tem 200 componentes, e 30% deles renderizam mesmo sem mudanças visuais, você tem 1200 renders desnecessários em 10 segundos. 120 renders por segundo. E tudo alimentado pelos batimentos do seu processador.

As três camadas de desperdício

Camada 1: renderização virtual desnecessária (React Render Cycles)

O React cria uma árvore virtual, compara com a anterior, decide o que mudou. Mas se nada mudou visualmente, esse trabalho é puro desperdício.

Camada 2: reconciliação do DOM (React Reconciliation)

Mesmo que o Virtual DOM seja idêntico ao anterior, React ainda precisa comparar nós. Se você tem mil nós, e o algoritmo de diff é O(n), isso é custoso.

Camada 3: trabalho de JavaScript no thread principal

Tudo isso roda no thread principal. Enquanto React renderiza, o browser não pode responder a cliques, scrolls, ou animações. É bloqueio puro.

Auditoria: medindo o desperdício com precisão cirúrgica

Passo 1: React Profiler API – entendendo o que está acontecendo

React 16.5+

// Primeiro, você precisa investigar quais renders estão acontecendo

import { Profiler } from 'react';

function onRenderCallback(
  id,           // Nome único do profiler
  phase,        // "mount" ou "update"
  actualDuration, // Tempo real renderizado
  baseDuration,   // Sem memoization
  startTime,      // Quando React começou
  commitTime      // Quando React commitou no DOM
) {
  // Se actualDuration é 0, React renderizou mas não tocou no DOM
  // Se baseDuration é muito maior que actualDuration,
  // memoization está funcionando

  console.log({
    id,
    phase,
    actualDuration,
    baseDuration,
    memoizationGain: (
      (baseDuration - actualDuration) / baseDuration * 100
    ).toFixed(2) + '%'
  });
}

function App() {
  return (
    <Profiler id="App" onRender={onRenderCallback}>
      <Dashboard />
    </Profiler>
  );
}

Abra o DevTools, deixe rodar por 5 segundos. Anote:

  • Quantos renders aconteceram?
  • Qual foi a duração máxima de um render?
  • Qual foi a duração mínima?
  • Qual é a média?

Passo 2: detectando Context Consumers desnecessários

Este é o culpado número 1. Vamos expô-lo:

// Crie uma ferramenta para monitorar consumidores de contexto

const AppContext = React.createContext();

// Versão instrumentada
const useAppContext = () => {
  const context = useContext(AppContext);

  // ⚠️ Registre qual componente está consumindo
  useEffect(() => {
    const componentName = ???; // Como saber?

    console.log(
      `[CONTEXT-CONSUMER] ${componentName} subscribed to AppContext`
    );
  }, []);

  return context;
};

// Problema: Você não sabe qual componente está consumindo.
// Solução: Use uma custom hook que rastreia automaticamente

Aqui está uma ferramenta prática para isso:

function useContextDebug(context, debugName) {
  const value = useContext(context);
  const rendersRef = useRef(0);
  const prevValueRef = useRef(value);

  useEffect(() => {
    // Log sempre que o contexto muda
    if (prevValueRef.current !== value) {
      console.log(
        `[${debugName}] Context changed. Renders so far: ${
          ++rendersRef.current
        }`
      );
      prevValueRef.current = value;
    }
  }, [value, debugName]);

  // Log do componente que está renderizando
  useEffect(() => {
    const error = new Error();
    const stack = error.stack
      .split('\n')
      .slice(2, 5)
      .join('\n');

    console.log(
      `[${debugName}] Rendered by:\n${stack}`
    );
  });

  return value;
}

Use assim:

function Header() {
  // Agora você vê quando e por quê Header renderiza
  const { user } = useContextDebug(AppContext, 'Header');
  return <div>{user.name}</div>;
}

Passo 3: Profiling em tempo de produção com web performance API

Para medir de verdade, não em dev

class RenderPerformanceMonitor {
  constructor() {
    this.renders = [];
    this.longTasks = [];

    // Observa "Long Tasks" (JS bloqueando por > 50ms)
    if (PerformanceObserver) {
      const observer = new PerformanceObserver(
        (list) => {
          for (const entry of list.getEntries()) {
            this.longTasks.push({
              duration: entry.duration,
              startTime: entry.startTime,
              name: entry.name,
            });

            // Alerta se duração > 50ms
            if (entry.duration > 50) {
              console.warn(
                `⚠️ LONG TASK: ${entry.duration.toFixed(0)}ms `
              );
            }
          }
        }
      );

      observer.observe({ entryTypes: ['longtask'] });
    }
  }

  recordRender(componentName, duration) {
    this.renders.push({
      component: componentName,
      duration,
      timestamp: performance.now(),
    });
  }

  getReport() {
    const totalRenders = this.renders.length;
    const avgDuration = 
      this.renders.reduce((acc, r) => acc + r.duration, 0) / 
      totalRenders;
    const maxDuration = Math.max(
      ...this.renders.map(r => r.duration)
    );

    return {
      totalRenders,
      avgDuration: avgDuration.toFixed(2),
      maxDuration: maxDuration.toFixed(2),
      longTasks: this.longTasks.length,
      recommendation: avgDuration > 5 
        ? '⚠️ Too many expensive renders'
        : '✅ Acceptable',
    };
  }
}

// Globalmente
window.__renderMonitor = new RenderPerformanceMonitor();

// Use no Profiler
<Profiler 
  id="MyComponent" 
  onRender={(id, phase, actualDuration) => {
    __renderMonitor.recordRender(id, actualDuration);
  }}
>
  <MyComponent />
</Profiler>

Passo 4: identificando o padrão de vazamento

Agora que você tem dados, procure por este padrão:

  • Render 1 (0ms):Context muda, Dashboard re-renderiza em 2ms
  • Render 2 (10ms):Um componente filho (Header) re-renderiza em 0.5ms, mas seu conteúdo não mudou
  • Render 3 (15ms):Outro filho (Sidebar) re-renderiza em 0.3ms, também sem mudança visual
  • Render 4 (25ms):Charts re-renderizam em 8ms, dados são idênticos
  • Render 5 (50ms):Footer re-renderiza em 0.1ms, sem mudança

Diagnóstico: O Context está sendo consumido por múltiplos componentes. Apenas um deles (Charts) realmente usava o valor que mudou. Os outros renderizaram por causa da propagação do contexto.

A Verdade: Context API propagação é “tudo ou nada”. Você não pode dizer “apenas componentes que usam user_id re-renderizem”. Se alguma coisa dentro do contexto muda, tudo consume renderiza.

As soluções: Context Splitting, Zustand, e Atoms

Solução 1: Context Splitting (a abordagem mais simples)

Ao invés de um grande Context, divida em múltiplos pequenos:

RUIM: Um Context MonolíticoBOM: Múltiplos Contextos Focados
const AppContext = createContext({ user, theme, filters, data, notifications, analytics, });const UserContext = createContext(); const ThemeContext = createContext(); const FilterContext = createContext(); const DataContext = createContext();

Agora, quando `filters` muda, apenas componentes que consomem `FilterContext` renderizam.

Ganho Esperado: 40-60% redução em renders desnecessários para dashboards típicos.

Solução 2: Zustand (para estado Global sem propagação)

Zustand usa subscriptions, não Context. Componentes que consomem um slice específico renderizam apenas quando aquele slice muda.

import { create } from 'zustand';

const useStore = create((set) => ({
  user: null,
  theme: 'light',
  filters: {},
  data: [],

  // Ações
  setUser: (user) => set({ user }),
  setFilters: (filters) => set({ filters }),
  setData: (data) => set({ data }),
}));

// Componente 1: Só consome user
function Header() {
  // Renderiza APENAS quando user muda
  const user = useStore((state) => state.user);
  return <div>{user.name}</div>;
}

// Componente 2: Consome data + filters
function DataGrid() {
  // Renderiza quando data OU filters mudam, mas não quando user muda
  const data = useStore((state) => state.data);
  const filters = useStore((state) => state.filters);
  return <table>...</table>;
}

Chave: O selector `(state) => state.user` diz a Zustand: “só me renderize quando `user` muda”. Mesmo que `filters` mude, Header não renderiza.

Ganho Esperado: 70-90% redução em renders para dashboards com muitas atualizações de dados.

Solução 3: Atoms / Jotai

Atoms são o oposto de “estado global”. Cada pequeno pedaço de estado é independente:

import { atom, useAtom } from 'jotai';

// Cada valor é um atom separado
const userAtom = atom(null);
const themeAtom = atom('light');
const filtersAtom = atom({});
const dataAtom = atom([]);

// Em Header: Renderiza APENAS quando userAtom muda
function Header() {
  const [user] = useAtom(userAtom);
  return <div>{user?.name}</div>;
}

// Em DataGrid: Renderiza quando dataAtom OU filtersAtom mudam
function DataGrid() {
  const [data] = useAtom(dataAtom);
  const [filters] = useAtom(filtersAtom);
  return <table>...</table>;
}

Ganho Esperado: 80-95% redução. Componentes renderizam APENAS quando o atom exato que consomem muda.

Cenário prático: dashboard com 10.000+ Renders/Segundo

O problema real

Um cliente tem um dashboard de monitoramento. WebSocket traz dados em tempo real a cada 500ms. Dentro de cada update:

  • Preços mudam (20 valores)
  • Gráficos precisam ser redesenhados (10 gráficos)
  • Status de sistema muda (15 indicadores)
  • Notificações aparecem/desaparecem (5 itens)

Tudo isso em um único Context global. Resultado: Toda a árvore renderiza 50 vezes por segundo.

Análise de desperdício

// React Profiler dados (5 segundos)

Dashboard renders: 250
├─ Header renders: 250 (sem mudanças visuais: 248)
├─ Sidebar renders: 250 (sem mudanças: 245)
├─ PriceGrid renders: 250 (mudanças reais: 200)
├─ Charts renders: 250 (mudanças reais: 175)
└─ Notifications renders: 250 (mudanças reais: 5)

// Desperdício total:
248 + 245 + 0 + 75 + 245 = 813 renders desnecessários
// Percentual: 813 / 1250 = 65% DESPERDÍCIO

Refactor com Zustand

// Antes: Context monolítico

// Depois: Slices de Zustand separadas
const usePriceStore = create((set) => ({
  prices: {},
  setPrices: (prices) => set({ prices }),
}));

const useChartStore = create((set) => ({
  chartData: {},
  setChartData: (data) => set({ chartData: data }),
}));

const useNotificationStore = create((set) => ({
  notifications: [],
  addNotification: (n) => set((state) => ({
    notifications: [...state.notifications, n],
  })),
}));

// Agora cada componente renderiza APENAS quando seu store muda

function PriceGrid() {
  // Renderiza 200 vezes (mudanças reais)
  const prices = usePriceStore((state) => state.prices);
  return <>...</>;
}

function Charts() {
  // Renderiza 175 vezes (mudanças reais)
  const chartData = useChartStore(
    (state) => state.chartData
  );
  return <>...</>;
}

function Header() {
  // Renderiza 0 vezes (Header não muda em updates de preço)
  return <header>...</header>;
}

Resultado

MétricaAntes (Context)Depois (Zustand)Melhoria
Renders/5s1250380-70%
Avg Render Time3.2ms0.8ms-75%
Max Render Time18ms4.5ms-75%
CPU Usage45%12%-73%
Battery Drain15% per hour4% per hour-73%

O verdadeiro ganho: Que começou como “o dashboard está lento” virou “agora consigo manter o dashboard aberto o dia todo sem meu notebook ficar quente”.

O problema da Memoization errada

Quando React.memo e useMemo Não Ajudam

Muitos tentam “consertar” o problema com memoization:

" style="color: red; font-weight: bold;">❌ ISSO NÃO FUNCIONA

const Header = React.memo(({ user }) => {
  return <div>{user.name}</div>;
});

// O problema: user vem de um Context que muda frequentemente
// Mesmo que Header não mude, o Context muda, Header renderiza
// React.memo não ajuda porque a prop mudou

function Dashboard() {
  const { user, filters, data } = useContext(AppContext);
  return <Header user={user} />;
  // Toda vez que AppContext muda, user é "recriado" (ou parece ser)
  // Header renderiza mesmo sendo memoizado
}

A verdade: Context bypass todas as otimizações de memoization. Se você consome um Context, você renderiza quando ele muda, ponto.

A solução: separar “consumo” de “renderização”

✅ ISSO FUNCIONA

// Componente que consome do Context
function UserProvider({ children }) {
  const { user } = useContext(AppContext);
  return (
    <UserContext.Provider value={user}>
      {children}
    </UserContext.Provider>
  );
}

// Componente que renderiza
const Header = React.memo(() => {
  const user = useContext(UserContext);
  return <div>{user.name}</div>;
});

// Agora: UserProvider renderiza quando user muda (OK, é o job dele)
// Mas Header renderiza APENAS quando user muda (não quando filters muda)
// E se user for idêntico, Header não renderiza (graças a memo)

Padrões avançados: otimizando além do estado

Padrão 1: Compound Components com State Splitting

Para dashboards complexos, use compound components que próprios gerenciam estado:

// Um dashboard é feito de seções independentes

function Dashboard() {
  return (
    <>
      <PriceSection />
      <ChartSection />
      <NotificationSection />
    </>
  );
}

// Cada seção tem seu próprio estado local
function PriceSection() {
  const prices = usePriceStore((s) => s.prices);
  const [sortBy, setSortBy] = useState('name');

  // Esta seção renderiza:
  // 1. Quando prices muda (externo)
  // 2. Quando sortBy muda (local)

  return (
    <section>
      <SortButton value={sortBy} onChange={setSortBy} />
      <PriceTable prices={prices} sortBy={sortBy} />
    </section>
  );
}

Padrão 2: Debouncing de updates

Se dados chegam muito frequentemente, debounce o update:

import { useDebouncedCallback } from 'use-debounce';

function DashboardWithWebSocket() {
  const setData = useStore((s) => s.setData);

  // Ao invés de atualizar a cada mensagem,
  // aguarde 500ms de silêncio antes de atualizar
  const debouncedSetData = useDebouncedCallback(
    setData,
    500
  );

  useEffect(() => {
    const ws = new WebSocket('ws://...');

    ws.onmessage = (event) => {
      const data = JSON.parse(event.data);
      debouncedSetData(data); // Renderiza no máximo 2x por segundo
    };

    return () => ws.close();
  }, []);

  return <Dashboard />;
}

Padrão 3: Virtual Scrolling para listas grandes

Se você tem uma tabela com 1000+ linhas, renderize apenas as visíveis:

import { FixedSizeList } from 'react-window';

function LargeDataGrid({ rows }) {
  const Row = ({ index, style }) => (
    <div style={style}>
      <DataRow data={rows[index]} />
    </div>
  );

  return (
    <FixedSizeList
      height={600}
      itemCount={rows.length}
      itemSize={50}
      width='100%'
    >
      {Row}
    </FixedSizeList>
  );
}

// Resultado: Se a tabela tem 1000 linhas, mas apenas 12 são visíveis,
// apenas 12 componentes DataRow renderizam. Não 1000.

Teste do especialista: seu dashboard está otimizado?

Checklist de Otimização

  1. Abra React DevTools Profiler
    Grave 10 segundos de interação. Conta quantos renders cada componente teve.
    • BOM: Componentes renderizam 1-3 vezes
    • PREOCUPANTE: Componentes renderizam 10-50 vezes
    • CRÍTICO: Componentes renderizam 100+ vezes
  2. Cheque o “Render Without Changes”
    No Profiler, há uma coluna “Render duration”. Componentes com duração 0 (não tocaram no DOM) são candidatos para otimização.
  3. Teste o Browser DevTools Performance
    Ctrl+Shift+P → Performance → Record. Deixe rodar 10 segundos. Procure por “long tasks” (barra vermelha).
    • BOM: Nenhuma long task, ou < 5 no total
    • RUIM: > 10 long tasks, ou alguma > 200ms
  4. Teste em um Dispositivo Real com Bateria Baixa
    Abra seu dashboard em um notebook com 5% de bateria. Deixe rodar 5 minutos. Quanto a bateria caiu?
    • BOM: Menos de 3%
    • RUIM: Mais de 5%
  5. Procure por Context.Provider no Profiler
    Se você vê um component chamado “Context.Provider” renderizando frequentemente, é sinal que há over-subscription.
  6. Teste após remover memoization
    Remova todos React.memo e useMemo. Se o desempenho piora drasticamente, a memoization estava fazendo o trabalho pesado (não uma solução de verdade).

Comparativo de soluções: Context API vs. Zustand vs. Jotai vs. Signals

AspectoContext APIZustandJotaiSolid Signals
Renders DesnecessáriosAltoBaixoMuito BaixoZero
Curva de AprendizadoBaixaMédiaMédiaAlta
Bundle Size (min)0kb (built-in)2.2kb8kb15kb
DevTools SupportExcelenteBomMédioLimitado
Melhor Para DashboardsNãoSimSimSim
Migração FácilN/AFácilFácilDifícil

Recomendação para Dashboards Reais:

  1. Se seu dashboard é pequeno (< 5 seções): Context API + Context Splitting
  2. Se é médio (5-20 seções): Zustand
  3. Se é grande (20+ seções, muitos updates independentes): Jotai
  4. Se você está migrando de Vue 3 ou quer máxima performance: Solid Signals

Conclusão: Re-renders em larga escala

O problema com re-renders desnecessários é que você não vê. O usuário não vê. O dashboard “funciona”. Mas nos bastidores, CPU está sendo desperdiçada, bateria está drenando, processamento está acontecendo para nada.

O que você aprendeu neste artigo:

  1. Como diagnosticar o problema com React Profiler e Web Performance API
  2. Por que Context API é problemática em larga escala (propagação “tudo ou nada”)
  3. Como medir o desperdício em termos que importam (renders, CPU, bateria)
  4. Que memoization sozinha não resolve o problema de root cause
  5. Quando usar Context Splitting, Zustand, Jotai ou Atoms para cada cenário

O padrão é sempre o mesmo: Quanto menos componentes renderizam, e quanto menos frequentemente, melhor. Não é um truque, é física. Menos computação = menos CPU = menos bateria = interface mais responsiva.

A diferença entre um dashboard “funciona” e um que “realmente funciona bem” é exatamente isso: você entendeu o que está acontecendo nos bastidores e otimizou para a realidade, não para a teoria.