Rendre des états impossibles impossible dans React.js

Dans cet article, nous abordons la complexité liée à la gestion d'état qui provient du serveur, et nous allons y répondre en découvrant ensemble ce qu'est un état transitoire et comment le modéliser en TypeScript.

Rendre des états impossibles impossible dans React.js
Photo by Nathan Dumlao / Unsplash

Si vous avez déjà utilisé React.js pour récupérer des états en provenance d'un serveur HTTP, vous avez sûrement dû commencer par une requête HTTP asynchrone à l'aide de l'API Web Fetch en pensant que cela était suffisant pour récupérer des données.

Seulement là est le piège dans lequel tout le monde tombe, car ce n'est que la partie emmergée de l'iceberg : que se passe-t-il lorsque je suis hors connexion ? Est-ce que ma requête HTTP est toujours en cours de chargement ? Que se passe-t-il en cas d'erreur ?

Autant de questions que l'on peut se poser lorsque nous utilisons nos applications Web en conditions réelles : dans le métro, à l'étranger, en dehors de la ville, etc...

Dans cet article, nous abordons la complexité liée à la gestion d'état qui provient du serveur, et nous allons y répondre en découvrant ensemble ce qu'est un état transitoire et comment le modéliser en TypeScript.

Qu'est-ce qu'un état ?

Je vous vois venir, cette question n'a pas lieu d'être, bien évidemment qu'on sait ce qu'est un état si on utilise React.js !

Néanmoins, cette question est intéressante car elle nous permettra de nous rendre compte que nous ne prenons pas forcément toujours en considération l'ensemble des informations disponibles pour un objet.

Donc un état, c'est tout simplement une information qui décrit quelque chose à un instant T.

Par exemple, cela peut être l'état d'un glaçon : solide, liquide ou gaz.

Cependant, si vous avez déjà observé un glaçon fondre (comment ça vous n'aviez jamais pensé à faire ça un week-end d'été ?), vous vous rendrez vite compte qu'un glaçon a en réalité une infinité d'états : le glaçon est complètement solide, complètement fondu, à moitié fondu, au quart de fondre complètement, etc.

Un glaçon ne passe jamais d'un état solide à un état liquide instantanément quand on a le dos tourné sans qu'on ne puisse voir en réalité les transitions d'état derrière cet événement.

État transitoire

Si nous reprenons notre exemple précédent, l'état de notre glaçon est passé de son état solide à son état liquide par une multitude de transitions, c'est ce qu'on appelle un (ou plusieurs) état transitoire.

Un état transitoire est un état intermédiaire qui nous permet, mis bout à bout, de comprendre le passage d'une information à une autre (un glaçon solide, puis devenu liquide).

Un glaçon n'est jamais dans plusieurs états à la fois : il n'est pas solide et liquide en même temps, c'est l'un ou l'autre.

En réalité, il est tout à fait possible qu'un élément soit dans une superposition entre le solide, liquide et gazeux, c'est ce qu'on appelle l'état supercritique. Mais c'est bien un nouvel état ! Donc nous pouvons nous dire qu'il existe dans quatre état différent.

C'est l'histoire de ce qui s'est passé, et ces informations sont essentielles ! Sans cela, nous n'aurions pas pu savoir ce qui s'est réellement produit : est-ce que le glaçon était d'abord solide, puis liquide ? Ou l'inverse ? Ou bien n'est-ce pas le même glaçon ?

Sans cet état transitoire, nous manquons de contexte, et donc d'information ; notre expérience s'en voit alors dégradée. C'est la même chose pour les utilisateurs de vos applications.

Vous avez d'ailleurs probablement déjà rencontré ce terme dans certaines librairies ou frameworks sous l'appellation Transient State, en français état transitoire ou transitif.

Un peu de code

À quoi est-ce que cela nous mène ? On a bien compris qu'un objet peut passer d'un état à un autre via une succession de transitions, mais qu'est-ce qui est important à savoir pour la création de nos applications Web avec React.js ?

Prenons un exemple.

Imaginons que nous souhaitions créer un composant nous permettant de récupérer l'adresse IP d'un utilisateur qui visite notre application React.js en créant un composant.

import { useState, useEffect } from "react";
import z from "zod";

const internetProtocolResponseSchema = z.object({
  ip: z.string()
});

export default function InternetProcotolAddress() {
  const [internetProtocolAddress, setInternetProtocolAddress] = useState("");

  useEffect(() => {
    fetch("https://ipapi.co/json").then(response => {
      return internetProtocolResponseSchema.parse(response)
    }).then(response => {
      setInternetProtocolAddress(response.ip)
    });
  }, []);

  return (
    <p>
      Your IP address is {internetProtocolAddress}
    </p>
  );
}

Dans le code précédent, nous avons exporté un simple composant permettant d'afficher un paragraphe qui contiendra l'adresse IP de l'utilisateur via une requête à l'API ipapi.co.

C'est un code très classique qui ne devrait pas poser de soucis particuliers à personne si vous avez déjà créé des applications avec React.js.

Cependant, ce composant a une faille de design et de conception majeure.

Si un utilisateur accède à votre application dans un métro, avec une connexion dégradée et ralentie, il ne verra rien pendant un laps de temps. Puis il verra apparaître comme un flash son adresse IP à l'écran, sans vraiment savoir ce qui l'attendait ni pourquoi cela arrive aussi tard.

Son expérience est dégradée. C'est pour cela qu'il y a un état transitif.

Le piège est de rester dans notre zone de confort de développeur : ordinateur surpuissant par rapport à la moyenne des utilisateurs, navigateurs en versions Canary avec les dernières fonctionnalités et connexion fibrée à des vitesses allant parfois jusqu'à 10 fois celle d'un utilisateur lambda.

Sauf que tout cela n'est pas la réalité du terrain : les utilisateurs vont consulter vos applications Web sur différents appareils, parfois jamais mis à jour, sur un navigateur qui ne prend pas en charge toutes les dernières fonctionnalités, sur une connexion rurale ne dépassant guère les centaines de mégaoctets.

Et bien que cela ne résolve pas le problème de première peinture (First Contentful Paint), il n'en reste pas moins une pratique importante à comprendre et à utiliser sur vos applications Web.

Un et un font deux et un font trois

Pour pouvoir résoudre ce problème, et j'en suis persuadé, vos lèvres meurent d'envie de me le dire, nous pourrions tout à fait rajouter plusieurs autres états.

Un état pour le chargement : c'est pratique car cela nous permettra d'indiquer que quelque chose se passe.

Et un état pour les erreurs : utile s'il se passe quelque chose d'imprévu.

import { useState, useEffect } from "react";
import z from "zod";

const internetProtocolResponseSchema = z.object({
  ip: z.string()
});

export default function InternetProcotolAddress() {
  const [internetProtocolAddress, setInternetProtocolAddress] = useState("");
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    fetch("https://ipapi.co/json").then(response => {
      return internetProtocolResponseSchema.parse(response)
    }).then(response => {
      setInternetProtocolAddress(response.ip)
    }).catch(error => {
      setError(String(error));
    }).finally(() => {
      setLoading(false);
    });
  }, []);

  if (loading) {
    return (
      <p>
        Your IP address is loading, please wait...
      </p>
    );
  }

  if (error) {
    return (
      <p>
        Error while loading your IP address: {error}
      </p>
    );
  }

  return (
    <p>
      Your IP address is {internetProtocolAddress}
    </p>
  );
}

C'est déjà beaucoup mieux !

Désormais, nos utilisateurs vont pouvoir consulter notre application depuis n'importe quel endroit, et même si la connexion par moments peut s'affaiblir, nous indiquons que les données sont toujours en cours de chargement, et en cas d'erreur, nous affichons un simple message indiquant ce qui s'est passé.

Cela paraît anodin, mais cela améliore grandement l'expérience utilisateur de nos clients, et donc l'adoption progressive de notre application. Vous aussi, vous aimeriez savoir lorsque vous naviguez sur une nouvelle page ce qui se passe et pourquoi tant d'attente.

Néanmoins il y a une faille critique dans ce code qui peut arriver dans de rares cas par notre faute.

L'erreur humaine

Moi, être humain imparfait, j'invoque la connerie en mode attaque.

import { useState, useEffect } from "react";
import z from "zod";

const internetProtocolResponseSchema = z.object({
  ip: z.string()
});

export default function InternetProcotolAddress() {
  const [internetProtocolAddress, setInternetProtocolAddress] = useState("");
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    fetch("https://ipapi.co/json").then(response => {
      return internetProtocolResponseSchema.parse(response)
    }).then(response => {
      setInternetProtocolAddress(response.ip)
    }).catch(error => {
      setError(String(error));
    }).finally(() => {
      setLoading(false);
    });
  }, []);

  return (
    <div>
      <h1>Your IP Address</h1>
      {loading && (
        <p>Loading, please wait...</p> 
      )}
      {error && (
        <p>An error occurred: {error}</p> 
      )}
      <p>Your IP address is {internetProtocolAddress}</p>
    </div>
  );
}

Cherchez l'erreur. Vous l'avez trouvé ? Non ? Je vous explique.

À première vue, ce code paraît anodin, c'est probablement quelque chose d'ailleurs que vous avez déjà fait, permettant d'avoir directement dans le code JSX un affichage de l'état de chargement et des erreurs, et cela est très simple à rajouter après qu'un intégrateur a fait le travail.

Le piège ici se situe dans le paragraphe final.

Si votre requête est en cours de chargement, vous vous retrouverez avec les deux textes suivants, l'un après l'autre.

Loading, please wait...
Your IP address is

On a vu mieux : ici on affiche bien que l'adresse IP soit en cours de chargement, mais on affiche aussi directement le texte car il n'y a pas vraiment de condition pour l'affichage de l'adresse IP : on l'affiche et c'est tout.

Comme c'est un texte, c'est plutôt facile à corriger. Il suffirait d'écrire la condition suivante.

  return (
    <div>
      <h1>Your IP Address</h1>
      {loading && (
        <p>Loading, please wait...</p> 
      )}
      {error && (
        <p>An error occurred: {error}</p> 
      )}
      {internetProtocolAddress && (
        <p>Your IP address is {internetProtocolAddress}</p>
      )}
    </div>
  );

Cela marche car le langage JavaScript reconnaît les chaînes de caractères comme étant équivalentes au booléen false, mais que se passe-t-il si mon état est soudainement plus complexe ?

import { useState, useEffect } from "react";
import z from "zod";

const internetProtocolResponseSchema = z.object({
  ip: z.string(),
  country: z.string(),
  provider: z.string()
});

export default function InternetProcotolAddress() {
  const [internetProtocolInformations, setInternetProtocolInformations] = useState({
    ip: "",
    country: "",
    provider: ""
  });

  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    fetch("https://ipapi.co/json").then(response => {
      return internetProtocolResponseSchema.parse(response)
    }).then(response => {
      setInternetProtocolInformations(response.ip)
    }).catch(error => {
      setError(String(error));
    }).finally(() => {
      setLoading(false);
    });
  }, []);

  return (
    <div>
      <h1>Your IP Address</h1>
      {loading && (
        <p>Loading, please wait...</p> 
      )}
      {error && (
        <p>An error occurred: {error}</p> 
      )}
      {internetProtocolInformations && (
        <p>Your IP address is {internetProtocolInformations.ip}</p>
        <p>Your provider is {internetProtocolInformations.provider}</p>
      )}
    </div>
  );
}

Soudainement les choses se corsent, et les questions se soulèvent.

Quelle est la valeur par défaut idéale en attendant de charger une adresse ip ? Un objet vide ? Un objet avec des valeurs par défaut ? Lesquelles ? Comment vérifier que l'objet vide est équivalent à faux ?

Autant de questions qui soulèvent un point que nous avons développé auparavant : une information n'existe que dans un seul état, et vouloir avoir plusieurs états en même temps est une erreur et entraîne ce que l'on appelle communément un état impossible.

Un état impossible, impossible

Désormais, la question que tout le monde devrait se poser : existe-t-il une manière de pouvoir rendre un état impossible, impossible ?

Plusieurs manières s'offrent à nous, et nous allons développer une façon de faire cela ensemble en utilisant à notre avantage le langage TypeScript.

import { useState, useEffect } from "react";
import z from "zod";

type Loading = {
  loading: true
}

type Loaded<Response> = {
  loading: false,
  issue: false
  response: Response
}

type Issue = {
  loading: false
  issue: true
  reason: string
}

type Transient<Response> =
  | Loading
  | Issue
  | Loaded<Response>

const internetProtocolResponseSchema = z.object({
  ip: z.string(),
  country: z.string(),
  provider: z.string()
});

type InternetProtocol = z.infer<typeof internetProtocolResponseSchema>

export default function InternetProcotolAddress() {
  const [internetProtocolInformations, setInternetProtocolInformations] = useState<Transient<InternetProtocol>>({
    loading: true,
  });

  useEffect(() => {
    fetch("https://ipapi.co/json").then(response => {
      return internetProtocolResponseSchema.parse(response)
    }).then(response => {
      setInternetProtocolInformations({
        loading: false,
        issue: false,
        response
      })
    }).catch(error => {
      setInternetProtocolInformations({
        loading: false,
        issue: true,
        reason: String(error)
      });
    });
  }, []);

  return (
    <div>
      <h1>Your IP Address</h1>
      {internetProtocolInformations.loading ? (
        <p>Loading, please wait...</p>
      ) : internetProtocolInformations.issue ? (
        <p>An error occurred: {internetProtocolInformations.reason}</p>
      ) : (
        <>
          <p>Your IP address is {internetProtocolInformations.response.ip}</p>
          <p>Your provider is {internetProtocolInformations.response.provider}</p>
        </>
      )}
    </div>
  );
}

Nous allons détailler le code ci-dessus.

type Loading = {
  loading: true
}

type Loaded<Response> = {
  loading: false,
  issue: false
  response: Response
}

type Issue = {
  loading: false
  issue: true
  reason: string
}

type Transient<Response> =
  | Loading
  | Issue
  | Loaded<Response>

La première chose que nous rajoutons, c'est un type générique Transient qui va nous permettre de prendre une réponse (en provenance d'un serveur HTTP), et de pouvoir l'empaqueter dans un type qui réunira les trois états d'une réponse.

Une réponse ne peut donc être qu'en cours de chargement, arrêtée pour cause d'erreur, ou le chargement a réussi (avec les données).

Étant donné que cet état transitif ne peut pas avoir l'un ou l'autre en même temps, il va nous être très utile pour éviter de nous tromper à l'avenir.

  const [internetProtocolInformations, setInternetProtocolInformations] = useState<Transient<InternetProtocol>>({
    loading: true,
  });

Ici, nous indiquons dans l'appel à useState que nous souhaitons privilégier un état transitif, nos données se situant dans le type InternetProtocol que nous avons extrait depuis le schéma Zod que nous avons défini juste au dessus.

    fetch("https://ipapi.co/json").then(response => {
      return internetProtocolResponseSchema.parse(response)
    }).then(response => {
      setInternetProtocolInformations({
        loading: false,
        issue: false,
        response
      })
    }).catch(error => {
      setInternetProtocolInformations({
        loading: false,
        issue: true,
        reason: String(error)
      });
    });

C'est ici que nous gérons notre état transitif. Si vous avez suivi en même temps sur votre éditeur de code, vous devriez vous rendre compte qu'il est impossible d'avoir un état qui indique une erreur, et en même temps un chargement, ou bien un chargement et les données qui sont arrivées en même temps.

Nous avons donc réussi grâce à notre type générique à rendre les états impossibles impossible.

Vous avez remarqué ? Nous avons également supprimé les états React du composant lié au chargement et à l'erreur. C'est également un effet positif sur notre type d'état transitif : vous n'avez plus besoin de dupliquer le code pour pouvoir avoir un état transitif de données, tout cela est géré dans un seul et même état, simplifiant la compréhension cognitive de notre code.

  return (
    <div>
      <h1>Your IP Address</h1>
      {internetProtocolInformations.loading ? (
        <p>Loading, please wait...</p>
      ) : internetProtocolInformations.issue ? (
        <p>An error occurred: {internetProtocolInformations.reason}</p>
      ) : (
        <>
          <p>Your IP address is {internetProtocolInformations.response.ip}</p>
          <p>Your provider is {internetProtocolInformations.response.provider}</p>
        </>
      )}
    </div>
  );

La brique finale, l'affichage.

Ici, nous avons également le même effet positif que lors de notre appel HTTP : il est impossible visuellement de se tromper, ni même de représenter un état impossible à l'écran de nos utilisateurs.

Si vous avez testé d'utiliser la constante internetProtocolInformations, Vous avez dû vous rendre compte qu'elle ne contient dans son type un accès à une unique propriété qui est internetProtocolInformations.loading.

C'est tout à fait normal ! Notre type est censé nous empêcher d'utiliser la réponse HTTP si nous n'avons pas d'abord affiché un message de chargement.

Ensuite, pour pouvoir afficher notre message, nous devons discriminer notre type afin de savoir s'il s'agit bien d'une erreur, ou si nous pouvons continuer tranquillement à utiliser notre réponse.

Il est donc également impossible d'afficher une erreur et un texte de succès par exemple.

Et le plus génial dans tout ça est qu'il est tout à fait possible d'en faire un hook personnalisé !

import { useState, useEffect, Dispatch, SetStateAction } from "react";
import z from "zod";

type Loading = {
  loading: true
}

type Loaded<Payload> = {
  loading: false,
  issue: false
  payload: Payload
}

type Issue = {
  loading: false
  issue: true
  reason: string
}

type Transient<Payload> =
  | Loading
  | Issue
  | Loaded<Payload>

const internetProtocolResponseSchema = z.object({
  ip: z.string(),
  country: z.string(),
  provider: z.string()
});

type InternetProtocol = z.infer<typeof internetProtocolResponseSchema>

export function useTransientState<State>(request: (setTransientState: Dispatch<SetStateAction<Transient<State>>>) => Promise<void>) {
  const [transientState, setTransientState] = useState<Transient<State>>({
    loading: true
  });

  useEffect(() => {
    request(setTransientState).catch(error => {
      setTransientState({
        loading: false,
        issue: true,
        reason: String(error)
      });
    })
  }, []);

  return transientState
}

export default function InternetProcotolAddress() {
  const internetProtocolInformations = useTransientState<InternetProtocol>(async setTransientState => {
    const response = await fetch("https://ipapi.co/json")
    const payload = internetProtocolResponseSchema.parse(response)

    setTransientState({
      loading: false,
      issue: false,
      payload
    })
  });

  return (
    <div>
      <h1>Your IP Address</h1>
      {internetProtocolInformations.loading ? (
        <p>Loading, please wait...</p>
      ) : internetProtocolInformations.issue ? (
        <p>An error occurred: {internetProtocolInformations.reason}</p>
      ) : (
        <>
          <p>Your IP address is {internetProtocolInformations.payload.ip}</p>
          <p>Your provider is {internetProtocolInformations.payload.provider}</p>
        </>
      )}
    </div>
  );
}

Pas mal, non ? Ici j'ai renommé Response en Payload pour pouvoir correctement identifier la réponse HTTP de son contenu.

En plus de cela, nous pouvons désormais contourner une limitation de useEffect : pouvoir utiliser le mot-clé async sans problème.

Et enfin, plus besoin de devoir gérer les erreurs inattendues explicitement, c'est fait directement dans le hook que nous avons créé au-dessus.

StAtE MaChInEs WiLl StEaL OuR JoBs

Nous avons vu ici l'importance de comprendre comment fonctionne un état et les différents états que peut prendre une donnée qui provient d'un serveur HTTP.

Nous n'avons égratiné ici que la surface et il serait intéressant de couvrir plus de cas qui sont liés aux requêtes HTTP comme par exemple les erreurs liées aux réseaux ou la possibilité de pouvoir annuler une requête.

Un état transitif n'est qu'une des nombreuses méthodes qui permettent de résoudre ce genre de problèmes, et rien ne vous empêche de préférer avoir plusieurs états, néanmoins, vous connaissez désormais les limites de raisonner comme cela, et vous connaissez une des solutions qui existent pour contourner ce souci.

Continuez d'apprendre et si vous souhaitez aller plus loin, n'hésitez pas à regarder du côté des machines à état avec des frameworks comme XState pour pouvoir faire des choses bien plus avancées !