Português

Domine o React Suspense para busca de dados. Aprenda a gerenciar estados de carregamento de forma declarativa, melhore a UX com transições e trate erros com Error Boundaries.

Limites de Suspense do React: Um Mergulho Profundo no Gerenciamento Declarativo de Estados de Carregamento

No mundo do desenvolvimento web moderno, criar uma experiência de usuário fluida e responsiva é fundamental. Um dos desafios mais persistentes que os desenvolvedores enfrentam é o gerenciamento de estados de carregamento. Desde a busca de dados para um perfil de usuário até o carregamento de uma nova seção de uma aplicação, os momentos de espera são críticos. Historicamente, isso envolveu uma teia emaranhada de flags booleanas como isLoading, isFetching e hasError, espalhadas por nossos componentes. Essa abordagem imperativa polui nosso código, complica a lógica e é uma fonte frequente de bugs, como condições de corrida (race conditions).

Eis que surge o React Suspense. Inicialmente introduzido para divisão de código (code-splitting) com React.lazy(), suas capacidades se expandiram drasticamente com o React 18 para se tornar um mecanismo poderoso e de primeira classe para lidar com operações assíncronas, especialmente a busca de dados. O Suspense nos permite gerenciar estados de carregamento de maneira declarativa, mudando fundamentalmente a forma como escrevemos e raciocinamos sobre nossos componentes. Em vez de perguntar "Estou carregando?", nossos componentes podem simplesmente dizer: "Preciso desses dados para renderizar. Enquanto espero, por favor, mostre esta UI de fallback."

Este guia abrangente levará você em uma jornada desde os métodos tradicionais de gerenciamento de estado até o paradigma declarativo do React Suspense. Exploraremos o que são os limites de Suspense, como eles funcionam tanto para divisão de código quanto para busca de dados, e como orquestrar UIs de carregamento complexas que encantam seus usuários em vez de frustrá-los.

A Maneira Antiga: A Tarefa dos Estados de Carregamento Manuais

Antes de podermos apreciar plenamente a elegância do Suspense, é essencial entender o problema que ele resolve. Vejamos um componente típico que busca dados usando os hooks useEffect e useState.

Imagine um componente que precisa buscar e exibir dados de um usuário:


import React, { useState, useEffect } from 'react';

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    // Reseta o estado para um novo userId
    setIsLoading(true);
    setUser(null);
    setError(null);

    const fetchUser = async () => {
      try {
        const response = await fetch(`https://api.example.com/users/${userId}`);
        if (!response.ok) {
          throw new Error('A resposta da rede não foi bem-sucedida');
        }
        const data = await response.json();
        setUser(data);
      } catch (err) {
        setError(err);
      } finally {
        setIsLoading(false);
      }
    };

    fetchUser();
  }, [userId]); // Busca novamente quando o userId muda

  if (isLoading) {
    return <p>Carregando perfil...</p>;
  }

  if (error) {
    return <p>Erro: {error.message}</p>;
  }

  return (
    <div>
      <h1>{user.name}</h1>
      <p>Email: {user.email}</p>
    </div>
  );
}

Este padrão é funcional, mas tem várias desvantagens:

Eis o React Suspense: Uma Mudança de Paradigma

O Suspense vira esse modelo de cabeça para baixo. Em vez de o componente gerenciar o estado de carregamento internamente, ele comunica sua dependência de uma operação assíncrona diretamente ao React. Se os dados de que precisa ainda não estiverem disponíveis, o componente "suspende" a renderização.

Quando um componente suspende, o React sobe na árvore de componentes para encontrar o Limite de Suspense (Suspense Boundary) mais próximo. Um Limite de Suspense é um componente que você define em sua árvore usando <Suspense>. Esse limite então renderizará uma UI de fallback (como um spinner ou um skeleton loader) até que todos os componentes dentro dele tenham resolvido suas dependências de dados.

A ideia central é colocalizar a dependência de dados com o componente que precisa dela, enquanto centraliza a UI de carregamento em um nível mais alto na árvore de componentes. Isso limpa a lógica do componente e oferece um controle poderoso sobre a experiência de carregamento do usuário.

Como um Componente "Suspende"?

A mágica por trás do Suspense reside em um padrão que pode parecer incomum a princípio: lançar uma Promise. Uma fonte de dados habilitada para Suspense funciona assim:

  1. Quando um componente solicita dados, a fonte de dados verifica se os tem em cache.
  2. Se os dados estiverem disponíveis, ela os retorna sincronicamente.
  3. Se os dados não estiverem disponíveis (ou seja, estão sendo buscados), a fonte de dados lança a Promise que representa a requisição de busca em andamento.

O React captura essa Promise lançada. Ele não quebra sua aplicação. Em vez disso, ele a interpreta como um sinal: "Este componente não está pronto para renderizar ainda. Pause-o e procure por um limite de Suspense acima dele para mostrar um fallback." Assim que a Promise for resolvida, o React tentará renderizar o componente novamente, que agora receberá seus dados e será renderizado com sucesso.

O Limite <Suspense>: Seu Declarador de UI de Carregamento

O componente <Suspense> é o coração deste padrão. É incrivelmente simples de usar, recebendo uma única prop obrigatória: fallback.


import { Suspense } from 'react';

function App() {
  return (
    <div>
      <h1>Minha Aplicação</h1>
      <Suspense fallback={<p>Carregando conteúdo...</p>}>
        <AlgumComponenteQueBuscaDados />
      </Suspense>
    </div>
  );
}

Neste exemplo, se AlgumComponenteQueBuscaDados suspender, o usuário verá a mensagem "Carregando conteúdo..." até que os dados estejam prontos. O fallback pode ser qualquer nó React válido, de uma simples string a um componente de skeleton complexo.

Caso de Uso Clássico: Divisão de Código com React.lazy()

O uso mais estabelecido do Suspense é para divisão de código (code splitting). Ele permite adiar o carregamento do JavaScript de um componente até que ele seja realmente necessário.


import React, { Suspense, lazy } from 'react';

// O código deste componente não estará no bundle inicial.
const ComponentePesado = lazy(() => import('./ComponentePesado'));

function App() {
  return (
    <div>
      <h2>Conteúdo que carrega imediatamente</h2>
      <Suspense fallback={<div>Carregando componente...</div>}>
        <ComponentePesado />
      </Suspense>
    </div>
  );
}

Aqui, o React só buscará o JavaScript para ComponentePesado na primeira vez que tentar renderizá-lo. Enquanto ele está sendo buscado e analisado, o fallback do Suspense é exibido. Esta é uma técnica poderosa para melhorar os tempos de carregamento inicial da página.

A Fronteira Moderna: Busca de Dados com Suspense

Embora o React forneça o mecanismo de Suspense, ele não fornece um cliente de busca de dados específico. Para usar o Suspense para busca de dados, você precisa de uma fonte de dados que se integre a ele (ou seja, uma que lance uma Promise quando os dados estiverem pendentes).

Frameworks como Relay e Next.js têm suporte nativo e de primeira classe para o Suspense. Bibliotecas populares de busca de dados como TanStack Query (anteriormente React Query) e SWR também oferecem suporte experimental ou completo a ele.

Para entender o conceito, vamos criar um wrapper conceitual muito simples em torno da API fetch para torná-la compatível com Suspense. Nota: Este é um exemplo simplificado para fins educacionais e não está pronto para produção. Faltam-lhe as complexidades de um cache adequado e tratamento de erros.


// data-fetcher.js
// Um cache simples para armazenar resultados
const cache = new Map();

export function fetchData(url) {
  if (!cache.has(url)) {
    cache.set(url, { status: 'pending', promise: fetchAndCache(url) });
  }

  const record = cache.get(url);

  if (record.status === 'pending') {
    throw record.promise; // Esta é a mágica!
  }
  if (record.status === 'error') {
    throw record.error;
  }
  if (record.status === 'success') {
    return record.data;
  }
}

async function fetchAndCache(url) {
  try {
    const response = await fetch(url);
    if (!response.ok) {
      throw new Error(`Busca falhou com status ${response.status}`);
    }
    const data = await response.json();
    cache.set(url, { status: 'success', data });
  } catch (e) {
    cache.set(url, { status: 'error', error: e });
  }
}

Este wrapper mantém um status simples para cada URL. Quando fetchData é chamado, ele verifica o status. Se estiver pendente, ele lança a promise. Se for bem-sucedido, ele retorna os dados. Agora, vamos reescrever nosso componente UserProfile usando isso.


// UserProfile.js
import React, { Suspense } from 'react';
import { fetchData } from './data-fetcher';

// O componente que realmente usa os dados
function ProfileDetails({ userId }) {
  // Tenta ler os dados. Se não estiverem prontos, isso suspenderá.
  const user = fetchData(`https://api.example.com/users/${userId}`);

  return (
    <div>
      <h1>{user.name}</h1>
      <p>Email: {user.email}</p>
    </div>
  );
}

// O componente pai que define a UI do estado de carregamento
export function UserProfile({ userId }) {
  return (
    <Suspense fallback={<p>Carregando perfil...</p>}>
      <ProfileDetails userId={userId} />
    </Suspense>
  );
}

Veja a diferença! O componente ProfileDetails é limpo e focado exclusivamente em renderizar os dados. Ele não tem estados isLoading ou error. Ele simplesmente solicita os dados de que precisa. A responsabilidade de mostrar um indicador de carregamento foi movida para o componente pai, UserProfile, que declara o que mostrar enquanto espera.

Orquestrando Estados de Carregamento Complexos

O verdadeiro poder do Suspense torna-se aparente quando você constrói UIs complexas com múltiplas dependências assíncronas.

Limites de Suspense Aninhados para uma UI Escalonada

Você pode aninhar limites de Suspense para criar uma experiência de carregamento mais refinada. Imagine uma página de dashboard com uma barra lateral, uma área de conteúdo principal e uma lista de atividades recentes. Cada um desses elementos pode exigir sua própria busca de dados.


function DashboardPage() {
  return (
    <div>
      <h1>Dashboard</h1>
      <div className="layout">
        <Suspense fallback={<p>Carregando navegação...</p>}>
          <Sidebar />
        </Suspense>

        <main>
          <Suspense fallback={<ProfileSkeleton />}>
            <MainContent />
          </Suspense>

          <Suspense fallback={<ActivityFeedSkeleton />}>
            <ActivityFeed />
          </Suspense>
        </main>
      </div>
    </div>
  );
}

Com esta estrutura:

Isso permite que você mostre conteúdo útil ao usuário o mais rápido possível, melhorando drasticamente o desempenho percebido.

Evitando o Efeito "Pipoca" na UI

Às vezes, a abordagem escalonada pode levar a um efeito desconfortável onde múltiplos spinners aparecem e desaparecem em rápida sucessão, um efeito muitas vezes chamado de "popcorning". Para resolver isso, você pode mover o limite de Suspense para um nível mais alto na árvore.


function DashboardPage() {
  return (
    <div>
      <h1>Dashboard</h1>
      <Suspense fallback={<DashboardSkeleton />}>
        <div className="layout">
          <Sidebar />
          <main>
            <MainContent />
            <ActivityFeed />
          </main>
        </div>
      </Suspense>
    </div>
  );
}

Nesta versão, um único DashboardSkeleton é mostrado até que todos os componentes filhos (Sidebar, MainContent, ActivityFeed) tenham seus dados prontos. O dashboard inteiro então aparece de uma só vez. A escolha entre limites aninhados e um único limite de nível superior é uma decisão de design de UX que o Suspense torna trivial de implementar.

Tratamento de Erros com Error Boundaries

O Suspense lida com o estado pendente de uma promise, mas e quanto ao estado rejeitado? Se a promise lançada por um componente for rejeitada (por exemplo, um erro de rede), ela será tratada como qualquer outro erro de renderização no React.

A solução é usar Error Boundaries. Um Error Boundary é um componente de classe que define um método de ciclo de vida especial, componentDidCatch(), ou um método estático getDerivedStateFromError(). Ele captura erros de JavaScript em qualquer lugar de sua árvore de componentes filhos, registra esses erros e exibe uma UI de fallback.

Aqui está um componente Error Boundary simples:


import React from 'react';

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

  static getDerivedStateFromError(error) {
    // Atualiza o estado para que a próxima renderização mostre a UI de fallback.
    return { hasError: true, error: error };
  }

  componentDidCatch(error, errorInfo) {
    // Você também pode registrar o erro em um serviço de relatórios de erros
    console.error("Capturou um erro:", error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      // Você pode renderizar qualquer UI de fallback personalizada
      return <h1>Algo deu errado. Por favor, tente novamente.</h1>;
    }

    return this.props.children; 
  }
}

Você pode então combinar Error Boundaries com Suspense para criar um sistema robusto que lida com todos os três estados: pendente, sucesso e erro.


import { Suspense } from 'react';
import ErrorBoundary from './ErrorBoundary';
import { UserProfile } from './UserProfile';

function App() {
  return (
    <div>
      <h2>Informações do Usuário</h2>
      <ErrorBoundary>
        <Suspense fallback={<p>Carregando...</p>}>
          <UserProfile userId={123} />
        </Suspense>
      </ErrorBoundary>
    </div>
  );
}

Com este padrão, se a busca de dados dentro de UserProfile for bem-sucedida, o perfil é mostrado. Se estiver pendente, o fallback do Suspense é mostrado. Se falhar, o fallback do Error Boundary é mostrado. A lógica é declarativa, composicional e fácil de raciocinar.

Transições: A Chave para Atualizações de UI Não Bloqueantes

Há uma última peça no quebra-cabeça. Considere uma interação do usuário que aciona uma nova busca de dados, como clicar em um botão "Próximo" para ver um perfil de usuário diferente. Com a configuração acima, no momento em que o botão é clicado e a prop userId muda, o componente UserProfile suspenderá novamente. Isso significa que o perfil atualmente visível desaparecerá e será substituído pelo fallback de carregamento. Isso pode parecer abrupto e disruptivo.

É aqui que entram as transições. As transições são um novo recurso no React 18 que permite marcar certas atualizações de estado como não urgentes. Quando uma atualização de estado é envolvida em uma transição, o React continua exibindo a UI antiga (o conteúdo obsoleto) enquanto prepara o novo conteúdo em segundo plano. Ele só confirmará a atualização da UI quando o novo conteúdo estiver pronto para ser exibido.

A API principal para isso é o hook useTransition.


import React, { useState, useTransition, Suspense } from 'react';
import { UserProfile } from './UserProfile';

function ProfileSwitcher() {
  const [userId, setUserId] = useState(1);
  const [isPending, startTransition] = useTransition();

  const handleNextClick = () => {
    startTransition(() => {
      setUserId(id => id + 1);
    });
  };

  return (
    <div>
      <button onClick={handleNextClick} disabled={isPending}>
        Próximo Usuário
      </button>

      {isPending && <span> Carregando novo perfil...</span>}

      <ErrorBoundary>
        <Suspense fallback={<p>Carregando perfil inicial...</p>}>
          <UserProfile userId={userId} />
        </Suspense>
      </ErrorBoundary>
    </div>
  );
}

Aqui está o que acontece agora:

  1. O perfil inicial para userId: 1 carrega, mostrando o fallback do Suspense.
  2. O usuário clica em "Próximo Usuário".
  3. A chamada setUserId é envolvida em startTransition.
  4. O React começa a renderizar o UserProfile com o novo userId de 2 em memória. Isso faz com que ele suspenda.
  5. Crucialmente, em vez de mostrar o fallback do Suspense, o React mantém a UI antiga (o perfil do usuário 1) na tela.
  6. O booleano isPending retornado por useTransition se torna true, permitindo-nos mostrar um indicador de carregamento sutil e embutido sem desmontar o conteúdo antigo.
  7. Assim que os dados para o usuário 2 são buscados e o UserProfile pode ser renderizado com sucesso, o React confirma a atualização, e o novo perfil aparece de forma fluida.

As transições fornecem a camada final de controle, permitindo que você construa experiências de carregamento sofisticadas e amigáveis ao usuário que nunca parecem abruptas.

Melhores Práticas e Considerações Globais

Conclusão

O React Suspense representa mais do que apenas um novo recurso; é uma evolução fundamental na forma como abordamos a assincronicidade em aplicações React. Ao nos afastarmos das flags de carregamento manuais e imperativas e abraçarmos um modelo declarativo, podemos escrever componentes mais limpos, mais resilientes e mais fáceis de compor.

Ao combinar <Suspense> para estados pendentes, Error Boundaries para estados de falha e useTransition para atualizações fluidas, você tem um kit de ferramentas completo e poderoso à sua disposição. Você pode orquestrar tudo, desde simples spinners de carregamento até revelações complexas e escalonadas de dashboards com código mínimo e previsível. À medida que você começar a integrar o Suspense em seus projetos, descobrirá que ele não apenas melhora o desempenho e a experiência do usuário de sua aplicação, mas também simplifica drasticamente sua lógica de gerenciamento de estado, permitindo que você se concentre no que realmente importa: construir ótimos recursos.

Limites de Suspense do React: Um Mergulho Profundo no Gerenciamento Declarativo de Estados de Carregamento | MLOG