Como Usar Web Vitals em Aplicações React: LCP, CLS, INP e Otimizações em Produção Já leu

Web Vitals em Aplicações React: Entendendo as Métricas Essenciais Os Web Vitals são métricas de desempenho estabelecidas pelo Google para medir a experiência real do usuário em uma aplicação web. Em React, onde interatividade é fundamental, entender e otimizar essas métricas é absolutamente crítico. Diferentemente de métricas tradicionais como tempo de carregamento genérico, os Web Vitals focam especificamente no que importa: quando o usuário consegue ver o conteúdo, quando consegue interagir com ele, e se a página permanece estável visualmente enquanto isso acontece. Neste artigo, abordaremos três métricas Core Web Vitals: LCP (Largest Contentful Paint), CLS (Cumulative Layout Shift) e INP (Interaction to Next Paint). Cada uma delas representa um pilar diferente da experiência do usuário. LCP mede a velocidade percebida de carregamento, CLS mede a estabilidade visual, e INP mede a responsividade da aplicação. Entender como cada uma funciona e como otimizá-las em React é essencial para construir aplicações que não apenas funcionam bem, mas que são percebidas como

Web Vitals em Aplicações React: Entendendo as Métricas Essenciais

Os Web Vitals são métricas de desempenho estabelecidas pelo Google para medir a experiência real do usuário em uma aplicação web. Em React, onde interatividade é fundamental, entender e otimizar essas métricas é absolutamente crítico. Diferentemente de métricas tradicionais como tempo de carregamento genérico, os Web Vitals focam especificamente no que importa: quando o usuário consegue ver o conteúdo, quando consegue interagir com ele, e se a página permanece estável visualmente enquanto isso acontece.

Neste artigo, abordaremos três métricas Core Web Vitals: LCP (Largest Contentful Paint), CLS (Cumulative Layout Shift) e INP (Interaction to Next Paint). Cada uma delas representa um pilar diferente da experiência do usuário. LCP mede a velocidade percebida de carregamento, CLS mede a estabilidade visual, e INP mede a responsividade da aplicação. Entender como cada uma funciona e como otimizá-las em React é essencial para construir aplicações que não apenas funcionam bem, mas que são percebidas como rápidas e fluidas pelos usuários finais.

LCP (Largest Contentful Paint)

O que é LCP

LCP mede o tempo até que o maior elemento visível (geralmente uma imagem ou texto em bloco) seja renderizado na viewport. Tecnicamente, é quando esse elemento maior se torna "pintado" (painted) no navegador. Um bom LCP é abaixo de 2.5 segundos. Valores entre 2.5 e 4 segundos precisam de melhoria, e acima de 4 segundos é considerado ruim.

A questão fundamental aqui é: o usuário consegue ver algo significativo na tela rapidamente? Em aplicações React, onde o JavaScript controla a renderização, é comum que o LCP seja afetado pelo tempo necessário para fazer download, parse e execução do bundle JavaScript antes que o conteúdo principal apareça.

Identificando Problemas de LCP

Para diagnosticar problemas de LCP em sua aplicação React, use o Chrome DevTools ou a API PerformanceObserver. O código abaixo captura dados reais de LCP:

// Hook para medir LCP em React
import { useEffect } from 'react';

const useLCPMetric = () => {
  useEffect(() => {
    const observer = new PerformanceObserver((entryList) => {
      const entries = entryList.getEntries();
      const lastEntry = entries[entries.length - 1];

      console.log('LCP:', lastEntry.renderTime || lastEntry.loadTime);
      console.log('LCP Element:', lastEntry.element);
    });

    observer.observe({ type: 'largest-contentful-paint', buffered: true });

    return () => observer.disconnect();
  }, []);
};

export default useLCPMetric;

Este hook, quando utilizado no seu componente raiz, exibirá no console qual elemento é considerado o "maior conteúdo" e em quanto tempo ele foi renderizado. Frequentemente você descobrirá que a culpada é uma imagem grande ou um componente que depende de dados vindos de uma API.

Otimizações Práticas para LCP

1. Code Splitting e Lazy Loading: Ao invés de carregar todo o JavaScript de uma vez, divida seu bundle React em partes menores que são carregadas conforme necessário.

// src/App.jsx
import { lazy, Suspense } from 'react';

const HeavyDashboard = lazy(() => import('./pages/HeavyDashboard'));
const ProductList = lazy(() => import('./pages/ProductList'));

function App() {
  return (
    <Suspense fallback={<div>Carregando...</div>}>
      <Routes>
        <Route path="/dashboard" element={<HeavyDashboard />} />
        <Route path="/products" element={<ProductList />} />
      </Routes>
    </Suspense>
  );
}

2. Otimizar Imagens do LCP: Se a imagem é o maior conteúdo, comprima-a, use formatos modernos (WebP) e carregue versões responsivas:

// Componente com imagem otimizada para LCP
function HeroImage() {
  return (
    <picture>
      <source srcSet="/hero.webp" type="image/webp" />
      <source srcSet="/hero.jpg" type="image/jpeg" />
      <img 
        src="/hero.jpg" 
        alt="Hero"
        loading="eager"
        fetchPriority="high"
        width="1200"
        height="600"
        style={{ width: '100%', height: 'auto' }}
      />
    </picture>
  );
}

O atributo fetchPriority="high" instruí o navegador a priorizar essa imagem no download. loading="eager" garante que ela começa a carregar imediatamente.

3. Server-Side Rendering (SSR): Render a página no servidor para que o conteúdo principal chegue ao navegador já pronto, sem depender de JavaScript executar primeiro.

// server.js (com Express e React)
import express from 'express';
import { renderToString } from 'react-dom/server';
import App from './App.jsx';

const app = express();

app.get('/', (req, res) => {
  const html = renderToString(<App />);
  res.send(`
    <!DOCTYPE html>
    <html>
      <head>
        <title>Minha App</title>
      </head>
      <body>
        <div id="root">${html}</div>
        <script src="/bundle.js"></script>
      </body>
    </html>
  `);
});

app.listen(3000);

Com SSR, o navegador renderiza HTML puro imediatamente, enquanto o JavaScript para interatividade carrega em paralelo.

CLS (Cumulative Layout Shift)

O que é CLS

CLS mede quanto a página "pula" ou se move inesperadamente enquanto o usuário está interagindo com ela. Imagine você lendo um artigo e de repente um anúncio carrega empurrando o texto para baixo — isso é layout shift negativo. Um bom CLS é abaixo de 0.1, entre 0.1 e 0.25 precisa melhorar, e acima disso é ruim.

A fórmula técnica envolve o quanto de espaço foi deslocado multiplicado pela fração da viewport que foi afetada, mas o importante para você saber é: reserve espaço antecipadamente para elementos que serão carregados depois. Em React, onde componentes podem aparecer dinamicamente, isso é uma preocupação constante.

Diagnosticando CLS

// Hook para detectar layout shifts
import { useEffect } from 'react';

const useCLSMetric = () => {
  useEffect(() => {
    let clsValue = 0;

    const observer = new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        if (!entry.hadRecentInput) {
          clsValue += entry.value;
          console.log('Current CLS:', clsValue);
        }
      }
    });

    observer.observe({ type: 'layout-shift', buffered: true });

    return () => observer.disconnect();
  }, []);
};

export default useCLSMetric;

Execure este hook e interaja com sua página. Abra o console e observe quanto ela "pula". A propriedade hadRecentInput garante que shifts causados por ações do usuário (como digitar em um input) sejam ignorados, já que esses são esperados.

Práticas para Eliminar CLS

1. Reserve Espaço para Imagens e Iframes:

// ❌ RUIM - sem dimensões, causa CLS quando carrega
function BadImage() {
  return <img src="/photo.jpg" alt="Photo" />;
}

// ✅ BOM - dimensões declaradas, navegador reserva espaço
function GoodImage() {
  return (
    <img 
      src="/photo.jpg" 
      alt="Photo"
      width={800}
      height={600}
      style={{ width: '100%', height: 'auto' }}
    />
  );
}

2. Evite Inserir Conteúdo Acima do Já Carregado:

// ❌ RUIM - notificação aparece no topo empurrando tudo para baixo
function BadNotification({ message }) {
  return (
    <div>
      {message && <div className="notification">{message}</div>}
      <MainContent />
    </div>
  );
}

// ✅ BOM - reserva espaço ou coloca a notificação em posição fixa
function GoodNotification({ message }) {
  return (
    <div>
      <div style={{ minHeight: message ? '60px' : '0px' }}>
        {message && <div className="notification">{message}</div>}
      </div>
      <MainContent />
    </div>
  );
}

3. Use transform Ao Invés de Propriedades que Causam Reflow:

// CSS - evite left, top, width que causam recálculo de layout
.animated-box {
  transition: transform 0.3s ease-in-out;
}

.animated-box.active {
  transform: translateX(100px); /* ✅ BOM */
}

/* ❌ RUIM - causa reflow e layout shift */
.animated-box.active {
  left: 100px; /* Evite propriedades de posição */
}

4. Estabilize Componentes Dinâmicos:

function UserMenu() {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <div style={{ position: 'relative' }}>
      <button onClick={() => setIsOpen(!isOpen)}>Menu</button>
      {/* Reserve espaço fixo para o dropdown, ele não empurra nada */}
      <div 
        style={{
          position: 'absolute',
          top: '100%',
          minWidth: '200px',
          minHeight: isOpen ? '150px' : '0px',
        }}
      >
        {isOpen && (
          <ul>
            <li><a href="/profile">Perfil</a></li>
            <li><a href="/logout">Sair</a></li>
          </ul>
        )}
      </div>
    </div>
  );
}

INP (Interaction to Next Paint)

O que é INP

INP mede o tempo entre o usuário fazer algo (clicar, digitar, tocar) e o navegador exibir a próxima atualização visual em resposta. Um bom INP é abaixo de 200ms, entre 200 e 500ms precisa melhorar, e acima é ruim. Diferentemente de LCP que mede load e CLS que mede visual stability, INP mede responsividade.

Em aplicações React, INP é frequentemente degradado por lógica pesada em event handlers, renderizações custosas de componentes, ou ambos acontecendo sincronamente. A solução envolve quebrar trabalho pesado em pedaços menores usando técnicas como startTransition, Web Workers, ou simples debouncing.

Medindo INP

// Hook para monitorar INP
import { useEffect } from 'react';

const useINPMetric = () => {
  useEffect(() => {
    const observer = new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        console.log('INP:', entry.duration);
        console.log('Interaction Type:', entry.name);
      }
    });

    observer.observe({ type: 'event', buffered: true });

    return () => observer.disconnect();
  }, []);
};

export default useINPMetric;

Otimizando INP em React

1. Use startTransition para Updates Não-Urgentes:

// Versão clássica - tudo é síncrono, UI trava
function SearchBad() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);

  const handleChange = (e) => {
    const value = e.target.value;
    setQuery(value);
    // Filtrar 10k items sincronamente, UI trava
    setResults(expensiveFilter(value));
  };

  return (
    <>
      <input onChange={handleChange} />
      <ResultsList items={results} />
    </>
  );
}

// Versão otimizada com startTransition
import { useState, startTransition } from 'react';

function SearchGood() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  const [isPending, setIsPending] = useState(false);

  const handleChange = (e) => {
    const value = e.target.value;
    setQuery(value); // Atualização urgente - input responde imediatamente

    // Atualização não-urgente - resultados processados no fundo
    startTransition(() => {
      setResults(expensiveFilter(value));
      setIsPending(false);
    });

    setIsPending(true);
  };

  return (
    <>
      <input onChange={handleChange} value={query} />
      {isPending && <p>Buscando...</p>}
      <ResultsList items={results} />
    </>
  );
}

Com startTransition, o input responde imediatamente porque React prioriza a atualização de estado do query. A filtragem pesada acontece "em background" sem bloquear a interação do usuário.

2. Debounce Operações Pesadas:

// Hook customizado para debounce
function useDebounce(value, delay) {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const handler = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    return () => clearTimeout(handler);
  }, [value, delay]);

  return debouncedValue;
}

function DataTable({ initialData }) {
  const [searchTerm, setSearchTerm] = useState('');
  const debouncedSearchTerm = useDebounce(searchTerm, 300);

  // Apenas calcula quando debouncedSearchTerm muda (a cada 300ms no máximo)
  const filteredData = useMemo(() => {
    return initialData.filter(item => 
      item.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase())
    );
  }, [debouncedSearchTerm, initialData]);

  return (
    <>
      <input 
        value={searchTerm}
        onChange={(e) => setSearchTerm(e.target.value)}
        placeholder="Buscar..."
      />
      <Table data={filteredData} />
    </>
  );
}

Aqui, o usuário digita suavemente (cada keystroke é respondido imediatamente), mas a filtragem pesada só ocorre 300ms após o último keystroke.

3. Use Web Workers para Processamento Pesado:

// worker.js - executado em thread separada
self.onmessage = (event) => {
  const { data } = event;
  const results = processLargeDataset(data);
  self.postMessage(results);
};

// Component.jsx
function DataProcessor() {
  const [results, setResults] = useState([]);
  const workerRef = useRef(null);

  useEffect(() => {
    // Cria worker apenas uma vez
    workerRef.current = new Worker(new URL('./worker.js', import.meta.url));

    workerRef.current.onmessage = (event) => {
      setResults(event.data);
    };

    return () => workerRef.current.terminate();
  }, []);

  const handleProcess = (largeDataset) => {
    // Envia dados para o worker - UI não trava
    workerRef.current.postMessage(largeDataset);
  };

  return (
    <>
      <button onClick={() => handleProcess(hugeDataArray)}>Processar</button>
      {results && <DisplayResults data={results} />}
    </>
  );
}

Web Workers executam código em uma thread separada, completamente fora da thread principal que renderiza a UI.

4. Implemente Virtualization para Listas Grandes:

// Usando react-window para renderizar apenas itens visíveis
import { FixedSizeList as List } from 'react-window';

function LargeList({ items }) {
  // Em vez de renderizar 10k elementos, renderiza apenas ~30 visíveis
  return (
    <List
      height={600}
      itemCount={items.length}
      itemSize={35}
      width="100%"
    >
      {({ index, style }) => (
        <div style={style}>
          {items[index].name} - {items[index].value}
        </div>
      )}
    </List>
  );
}

Integração Completa e Monitoring

Enviando Web Vitals para um Serviço de Analytics

Em produção, você quer saber como os Web Vitals estão sendo percebidos pelos usuários reais. Aqui está como enviar essas métricas para um serviço externo:

// lib/web-vitals.js
export function reportWebVitals(metric) {
  // Envia para seu endpoint de analytics
  fetch('/api/metrics', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      name: metric.name,
      value: metric.value,
      id: metric.id,
      rating: metric.rating, // 'good', 'needs-improvement', 'poor'
      delta: metric.delta,
      timestamp: new Date().toISOString(),
      url: window.location.href,
      userAgent: navigator.userAgent,
    }),
  }).catch(err => console.error('Analytics failed:', err));
}

// src/main.jsx
import { getCLS, getFCP, getFID, getLCP, getINP, getTTFB } from 'web-vitals';
import { reportWebVitals } from './lib/web-vitals';

getCLS(reportWebVitals);
getFCP(reportWebVitals);
getFID(reportWebVitals); // Descontinuado, substituído por INP
getLCP(reportWebVitals);
getINP(reportWebVitals);
getTTFB(reportWebVitals);

Ou use uma biblioteca pronta como web-vitals:

npm install web-vitals

Checklist de Otimizações

Antes de considerar sua aplicação otimizada, verifique:

  • LCP: Imagens otimizadas com dimensões, code splitting implementado, considere SSR
  • CLS: Todos os elementos dinâmicos têm dimensões reservadas, transforms usados ao invés de propriedades de layout
  • INP: startTransition para updates não-urgentes, debounce implementado, trabalho pesado delegado a Web Workers

Conclusão

Os Web Vitals não são apenas números — eles representam a experiência concreta do seu usuário. LCP determina se sua aplicação parece rápida, CLS determina se ela parece estável, e INP determina se ela responde aos comandos do usuário. Em React, onde o controle é fino e granular, aplicar as técnicas corretas faz toda a diferença entre uma aplicação que funciona e uma que é prazer usar.

A prioridade deve ser: medir primeiro com PerformanceObserver ou as APIs do Chrome DevTools, identificar qual métrica está ruim, depois aplicar a otimização específica para aquela métrica. Não otimize tudo de uma vez; foque no que está degradando a experiência real dos usuários.

Referências


Artigos relacionados