Le suspense, ou comment arrêter d’écrire des isLoading

Suspense et les périmètres d’erreur en React, puis les fichiers loading.tsx et error.tsx de Next.js qui les posent pour vous.

Contexte

La gestion des états de chargement a toujours été pénible avec React.

C’est un problème plus large : on en vient rapidement à la gestion des promesses (Promise).

Historiquement, on a commencé par des useEffect :

const MyComponent = () => {
  const [isLoading, setIsLoading] = useState(false); // 👈 état
  const [data, setData] = useState(null);

  useEffect(() => {
    setIsLoading(true); // 👈 ça charge
    fetch(url)
      .then((response) => response.json())
      .then(setData)
      .finally(() => setIsLoading(false)); // 👈 ça ne charge plus
  }, []);

  if (isLoading) {
    return <Loader />; // 👈 gestion UI spécifique
  }

  return <Content data={data} />;
};

Des bibliothèques sont apparues pour nous aider, comme TanStack Query :

const MyComponent = () => {
  const { data, isLoading } = useQuery(query); // 👈 🙏 `useQuery`

  if (isLoading) {
    // 👈 mais on a toujours ça, et c’est pénible
    return <Loader />;
  }

  return <Content data={data} />;
};

Le problème

Le code ci-dessus expose bien la difficulté : gérer les différents états de chargement est complexe.

Pour la gestion des erreurs 💥, le constat est le même 😭

Nos composants se complexifient rapidement, chacun avec sa propre gestion d’états.

L’équipe de React est donc arrivée avec un nouveau paradigme : le suspense (Suspense).

ℹ️ Je simplifie un peu : le vrai changement de paradigme est le « rendu concurrent ». On y vient plus bas.

La solution : le suspense

Suspense permet d’indiquer à React qu’un chargement est en cours.

En pratique, ce sont les bibliothèques qui s’en chargent. Avec TanStack Query, on appelle simplement useSuspenseQuery au lieu de useQuery.

React est alors informé de l’état de chargement.

Mais qu’en fait-il ? 🤔

Il remonte l’arbre de rendu à la recherche du composant Suspense le plus proche. Ce dernier définit un fallback (un « contenu de secours »), affiché tant que ses enfants chargent.

Voyons comment ça s’utilise :

const MyComponent = () => {
  const { data } = useSuspenseQuery(query); // 👈 on ne gère que le cas passant 🎉

  return <Content data={data} />;
};

const App = () => {
  // 👇 le fallback est affiché pendant le chargement 🔃
  return (
    <Suspense fallback={<Loader />}>
      <MyComponent />
    </Suspense>
  );
};

Stylé, non ? 😁

Bonus : data n’est plus jamais undefined, puisque le composant n’est rendu qu’une fois les données arrivées.

ℹ️ Avec les composants serveur (on en parlera une autre fois), Suspense permet aussi de streamer le rendu de ses enfants.

Suspense ❤️ Next.js

Parlons maintenant de Next.js et de son App Router, entièrement construit autour du rendu concurrent.

Prenons l’arborescence suivante :

app
└── page.tsx

Pour l’instant, on a juste une page.

Vous savez sans doute qu’on peut définir un layout, qui prendra place autour de la page :

app
├── layout.tsx
└── page.tsx

Ce qui revient à écrire :

<Layout>
  <Page />
</Layout>

Mais saviez-vous qu’on peut aussi définir un fichier loading.tsx ?

app
├── layout.tsx
├── loading.tsx
└── page.tsx

Next pose alors le Suspense pour nous. Ça revient à écrire :

<Layout>
  <Suspense fallback={<Loading />}>
    <Page />
  </Suspense>
</Layout>

Mais Guillaume, il n’y a pas que l’état de chargement, il y a aussi l’état d’erreur 🤔

Bonne remarque, Jean-Michel.

C’est le moment de rectifier l’approximation de tout à l’heure.

Le rendu concurrent

J’ai présenté le suspense comme un mode de rendu. Ce n’est pas exact : le mode de rendu, c’est le rendu concurrent, arrivé avec React 18.

En résumé, il permet à React de rendre en priorité les parties critiques de l’arbre et de différer le reste.

Pour découper l’arbre, React utilise des périmètres (boundaries). Suspense est un périmètre de chargement.

Et n’y a-t-il pas un autre type de périmètre qu’on connaît déjà ?

Oui : les périmètres d’erreur (error boundaries) !

Même principe, mais pour les erreurs. Le hic, c’est qu’il faut encore les écrire sous forme de classe, et ce n’est pas très beau :

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

  static getDerivedStateFromError(error) {
    // Met à jour l’état pour que le prochain rendu affiche l’UI de secours.
    return { hasError: true };
  }

  componentDidCatch(error, info) {
    logErrorToMyService(error, info.componentStack);
  }

  render() {
    if (this.state.hasError) {
      return this.props.fallback;
    }

    return this.props.children;
  }
}

Bonne nouvelle : personne ne l’écrit à la main. La bibliothèque react-error-boundary en fournit un tout prêt.

Notre rendu peut maintenant ressembler à ça :

const MyComponent = () => {
  const { data } = useSuspenseQuery(query); // 👈 toujours que le cas passant 🎉

  return <Content data={data} />;
};

const App = () => {
  return (
    <ErrorBoundary fallback={<Boom />}>
      <Suspense fallback={<Loader />}>
        <MyComponent />
      </Suspense>
    </ErrorBoundary>
  );
};

Et avec Next, c’est encore plus simple : il suffit d’ajouter un fichier error.tsx.

app
├── error.tsx
├── layout.tsx
├── loading.tsx
└── page.tsx

Next pose alors les deux périmètres pour nous :

<Layout>
  <ErrorBoundary fallback={<Error />}>
    <Suspense fallback={<Loading />}>
      <Page />
    </Suspense>
  </ErrorBoundary>
</Layout>

Attention : error.tsx doit être un composant client ('use client' en première ligne), car un périmètre d’erreur vit dans le navigateur.

Voilà, vous savez tout sur le suspense ❤️

Une question ? Retrouvez-moi sur LinkedIn.

Ressources