Error Boundaries e tratamento de falhas no frontend

1. Fundamentos do Tratamento de Erros em React

1.1. Erros em JavaScript: tipos, propagação e o ciclo de vida no React

Erros em JavaScript podem ser classificados em três categorias principais: erros de sintaxe (detectados em tempo de compilação), erros de tempo de execução (como TypeError ou ReferenceError) e erros lógicos (que não lançam exceções, mas produzem resultados incorretos). No React, erros durante a renderização, em métodos de ciclo de vida ou em construtores de componentes podem quebrar toda a árvore de componentes, resultando em uma tela branca e uma experiência de usuário catastrófica.

// Exemplo de erro que quebra a aplicação
function ComponentePropenso() {
  const dados = null;
  return <div>{dados.propriedadeInexistente}</div>; // TypeError
}

1.2. Limitações do try/catch no contexto de componentes React

O try/catch tradicional funciona bem para código imperativo, mas falha em capturar erros durante a renderização de componentes React. Isso ocorre porque o React usa uma abordagem declarativa e o ciclo de renderização é gerenciado internamente pelo framework.

function App() {
  try {
    return <ComponentePropenso />; // try/catch NÃO captura este erro
  } catch (erro) {
    return <div>Erro capturado: {erro.message}</div>; // Isso nunca executa
  }
}

1.3. O papel do Error Boundary na arquitetura de componentes

Error Boundaries são componentes React que capturam erros JavaScript em qualquer lugar da árvore de componentes abaixo deles, registram esses erros e exibem uma UI de fallback em vez da árvore de componentes que quebrou. Eles são a única maneira nativa de lidar com erros de renderização em React.

2. Implementando Error Boundaries Clássicos

2.1. Métodos de ciclo de vida: static getDerivedStateFromError() e componentDidCatch()

Para criar um Error Boundary clássico, utilizamos dois métodos específicos do ciclo de vida:
- static getDerivedStateFromError(error): atualiza o estado para renderizar a UI de fallback
- componentDidCatch(error, errorInfo): usado para logging do erro

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error) {
    return { hasError: true };
  }

  componentDidCatch(error, errorInfo) {
    console.error('Erro capturado:', error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      return this.props.fallback || <h1>Algo deu errado.</h1>;
    }
    return this.props.children;
  }
}

2.2. Criando um componente ErrorBoundary reutilizável

class ErrorBoundary extends React.Component {
  state = { hasError: false, error: null };

  static getDerivedStateFromError(error) {
    return { hasError: true, error };
  }

  componentDidCatch(error, errorInfo) {
    // Enviar para serviço de logging
    logErrorToService(error, errorInfo);
  }

  handleRetry = () => {
    this.setState({ hasError: false, error: null });
  };

  render() {
    if (this.state.hasError) {
      return (
        <div role="alert">
          <h2>Ops! Encontramos um problema.</h2>
          <p>{this.state.error?.message}</p>
          <button onClick={this.handleRetry}>Tentar novamente</button>
        </div>
      );
    }
    return this.props.children;
  }
}

2.3. Estratégias de fallback UI: mensagens, botões de retry e logging

A UI de fallback deve ser informativa e acionável. Inclua botões de retry, mensagens claras e, se possível, um link para suporte.

<ErrorBoundary fallback={<CustomFallback />}>
  <Dashboard />
</ErrorBoundary>

3. Error Boundaries com React Hooks

3.1. A falta de hooks nativos para Error Boundaries e a solução com react-error-boundary

React não fornece hooks nativos para Error Boundaries porque eles exigem métodos de ciclo de vida de classe. O pacote react-error-boundary preenche essa lacuna oferecendo uma API baseada em hooks.

3.2. Usando useErrorHandler e ErrorBoundary do pacote react-error-boundary

import { ErrorBoundary, useErrorHandler } from 'react-error-boundary';

function ComponenteComErro() {
  const handleError = useErrorHandler();

  const fazerRequisicao = async () => {
    try {
      await fetchDados();
    } catch (erro) {
      handleError(erro); // Delega o erro ao ErrorBoundary mais próximo
    }
  };

  return <button onClick={fazerRequisicao}>Carregar dados</button>;
}

function App() {
  return (
    <ErrorBoundary
      FallbackComponent={ErrorFallback}
      onError={(error, info) => logError(error, info)}
      onReset={() => limparEstado()}
    >
      <ComponenteComErro />
    </ErrorBoundary>
  );
}

3.3. Custom hooks para resetar estado e re-renderizar após falha

function useErrorBoundary() {
  const [error, setError] = useState(null);

  const handleError = useCallback((erro) => {
    setError(erro);
  }, []);

  const resetError = useCallback(() => {
    setError(null);
  }, []);

  return { error, handleError, resetError };
}

4. Tratamento de Erros em Requisições Assíncronas

4.1. Gerenciamento de erros em fetch e axios com interceptors

// Axios interceptor global
axios.interceptors.response.use(
  response => response,
  error => {
    if (error.response?.status === 401) {
      redirecionarParaLogin();
    }
    return Promise.reject(error);
  }
);

// Fetch com tratamento de erro
async function fetchComTratamento(url) {
  try {
    const response = await fetch(url);
    if (!response.ok) {
      throw new Error(`HTTP ${response.status}: ${response.statusText}`);
    }
    return await response.json();
  } catch (erro) {
    console.error('Falha na requisição:', erro);
    throw erro;
  }
}

4.2. Tratamento de erros em React Query e SWR

// React Query
const { data, error, isLoading, refetch } = useQuery('dados', fetchDados, {
  retry: 3,
  retryDelay: attempt => Math.min(1000 * 2 ** attempt, 30000),
  onError: (error) => {
    mostrarToast(`Erro: ${error.message}`);
  }
});

// SWR
const { data, error, mutate } = useSWR('/api/dados', fetcher, {
  onError: (error) => {
    logError(error);
  },
  onErrorRetry: (error, key, config, revalidate, { retryCount }) => {
    if (retryCount >= 3) return;
    setTimeout(() => revalidate({ retryCount }), 5000);
  }
});

4.3. Exibindo erros de API no frontend com toasts e banners contextuais

function ErrorBanner({ error, onRetry }) {
  if (!error) return null;
  return (
    <div className="error-banner" role="alert">
      <span>{error.message}</span>
      <button onClick={onRetry}>Tentar novamente</button>
    </div>
  );
}

5. Logging e Monitoramento de Erros no Frontend

5.1. Integrando Error Boundaries com serviços de logging (Sentry, LogRocket)

import * as Sentry from '@sentry/react';

Sentry.init({
  dsn: 'https://seu-dsn@sentry.io/project-id',
  environment: process.env.NODE_ENV,
  integrations: [new Sentry.BrowserTracing()]
});

// ErrorBoundary com Sentry
class SentryErrorBoundary extends React.Component {
  componentDidCatch(error, errorInfo) {
    Sentry.captureException(error, { extra: errorInfo });
  }
  // ... resto da implementação
}

5.2. Enriquecendo logs com contexto: user, ação, estado do componente

function logError(error, context = {}) {
  const enrichedError = {
    error: error.message,
    stack: error.stack,
    timestamp: new Date().toISOString(),
    user: getUserContext(),
    action: context.action,
    componentState: context.state,
    url: window.location.href
  };

  // Enviar para serviço de logging
  fetch('/api/logs', {
    method: 'POST',
    body: JSON.stringify(enrichedError)
  });
}

5.3. Estratégias de agregação e filtragem para evitar sobrecarga de logs

Implemente debouncing para erros repetidos e agregação por tipo de erro:

const errorCache = new Map();

function logErrorWithDedup(error, maxOccurrences = 10) {
  const key = `${error.name}:${error.message}`;
  const count = (errorCache.get(key) || 0) + 1;

  if (count <= maxOccurrences) {
    errorCache.set(key, count);
    sendToLoggingService(error);
  }
}

6. Estratégias de Fallback e Recuperação

6.1. Fallbacks granulares: Error Boundaries aninhados por seção da página

function Pagina() {
  return (
    <ErrorBoundary fallback={<HeaderFallback />}>
      <Header />
      <ErrorBoundary fallback={<SidebarFallback />}>
        <Sidebar />
        <ErrorBoundary fallback={<ContentFallback />}>
          <MainContent />
        </ErrorBoundary>
      </ErrorBoundary>
      <Footer />
    </ErrorBoundary>
  );
}

6.2. Padrão de retry com backoff exponencial no frontend

async function retryWithBackoff(fn, maxRetries = 3) {
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      return await fn();
    } catch (error) {
      if (attempt === maxRetries - 1) throw error;
      const delay = Math.pow(2, attempt) * 1000;
      await new Promise(resolve => setTimeout(resolve, delay));
    }
  }
}

6.3. Recuperação de estado: resetando Error Boundaries e mantendo dados intactos

function useErrorRecovery(initialState) {
  const [state, setState] = useState(initialState);
  const [error, setError] = useState(null);

  const reset = useCallback(() => {
    setError(null);
    setState(initialState);
  }, [initialState]);

  return { state, setState, error, setError, reset };
}

7. Testando Tratamento de Erros

7.1. Simulando erros em testes unitários com Jest e Testing Library

import { render, screen } from '@testing-library/react';

test('exibe fallback quando componente quebra', () => {
  const ComponenteQuebrado = () => {
    throw new Error('Erro simulado');
  };

  render(
    <ErrorBoundary fallback={<div>Erro capturado</div>}>
      <ComponenteQuebrado />
    </ErrorBoundary>
  );

  expect(screen.getByText('Erro capturado')).toBeInTheDocument();
});

7.2. Testando o comportamento de Error Boundaries

test('chama callback onError quando ocorre erro', () => {
  const onError = jest.fn();
  const ComponenteQuebrado = () => {
    throw new Error('Erro de teste');
  };

  render(
    <ErrorBoundary onError={onError} fallback={<div>Fallback</div>}>
      <ComponenteQuebrado />
    </ErrorBoundary>
  );

  expect(onError).toHaveBeenCalledTimes(1);
  expect(onError).toHaveBeenCalledWith(expect.any(Error), expect.any(Object));
});

7.3. Testes de integração para fluxos de erro com mocks de API

import { rest } from 'msw';
import { setupServer } from 'msw/node';

const server = setupServer(
  rest.get('/api/dados', (req, res, ctx) => {
    return res(ctx.status(500), ctx.json({ message: 'Erro interno' }));
  })
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

test('exibe toast de erro quando API falha', async () => {
  render(<Dashboard />);
  await waitFor(() => {
    expect(screen.getByRole('alert')).toHaveTextContent('Erro interno');
  });
});

Referências