La gestion des erreurs côté front : approches modernes avec JavaScript, TypeScript et React
Publié le 08 janvier 2025
La gestion des erreurs côté front est une pratique cruciale pour garantir une bonne expérience utilisateur. Que ce soit pour gérer des problèmes liés au réseau, des bugs inattendus ou des erreurs consécutives aux actions de l'utilisateur, il est important d'adopter une stratégie efficace. Explorons dans cet article comment capturer et gérer les erreurs dans un projet moderne utilisant JavaScript, TypeScript et React.
JavaScript
Pour mieux visualiser la gestion des erreurs en JavaScript, je la divise en deux parties : d’un côté, le code qui lance les erreurs (“throw”) et de l’autre, celui qui les intercepte le code (“catch”). À l'image du baseball, il y a des lanceurs et des receveurs, chacun ayant un rôle spécifique. ⚾
- Les lanceurs sont les parties du code qui déclenchent des erreurs lorsqu'un problème survient. Par exemple : un parseur JSON qui lève une exception lorsque le format est invalide, une fonction d'authentification qui déclenche une erreur si les identifiants fournis sont incorrects, ou une fonction qui échoue si une ressource externe est indisponible. Le langage JavaScript lance ses propres erreurs, comme une erreur de syntaxe ou de référence.
- Les receveurs sont les parties du code qui interceptent ces erreurs pour les traiter. Cela inclut, par exemple, un formulaire de connexion qui affiche un message d'erreur clair à l'utilisateur lorsqu'une authentification échoue, ou un composant React qui, lorsqu'un appel API échoue, affiche un message d'erreur convivial comme "Impossible de charger les données. Veuillez réessayer."
Note : une fonction peut avoir les deux casquettes (de baseball) : être à la fois lanceuse et receveuse.
Bonne nouvelle, il n’existe qu’une seule façon d’intercepter une erreur :
try {
// Code...
} catch (err) {
console.error(err); // Passe par ici si une erreur est catch
}
Pour qu'un code génère une erreur et soit capturé dans le bloc catch, il faut soit que le moteur JavaScript lance une erreur (faute de syntaxe, accès à une propriété d’objet qui n’existe pas, etc.1), soit que notre code utilise l’instruction throw afin de déclencher volontairement une erreur.
1La liste des erreurs JavaScript natives est disponible ici, on pourra noter que chacun des exemples est présenté dans ce fameux bloc try…catch.
Que se passe-t-il si on ne catche jamais rien explicitement ? Côté front, le client, par exemple votre navigateur, va logger l’erreur dans la console (et entraîner possiblement des anomalies plus ou moins impactantes, voire le crash complet de l’application !). Si vous utilisez un framework comme Next.js et que vous êtes en mode développement, une fenêtre “Unhandled Runtime Error” va surgir : message pratique vous suggérant de justement gérer l’erreur avant qu’elle n’arrive en production.
Côté back, si vous faites un simple script Node.js par exemple, le script va tout simplement stopper immédiatement.
N’importe où dans votre code, vous pouvez utiliser l’instruction throw :
throw "une erreur s'est produite !"
Bon, si vous placez ce bout de code dès le début, c’est sûr que votre application ne va pas être super utile. 😹 Tout ce qui suit cette instruction ne sera jamais exécuté :
throw "une erreur s'est produite !"
console.log("Coucou Maman !") // Ne sera jamais log 😿
Ici, nous avons lancé une chaîne de caractères, mais on peut aussi envoyer : un nombre, un booléen ou tout objet JS. On peut donc throw une fonction, car en JavaScript une fonction est un objet.
Plutôt que de balancer tout et n’importe quoi, on va lancer un type d’objet bien précis : un objet Error
. Vous vous souvenez de la liste des erreurs natives en JavaScript évoquée un peu plus haut ? Pour faciliter la gestion de nos propres erreurs, nous allons faire comme JavaScript et throw des objets similaires.
L’objet Error possède deux propriétés principales : name
et message
. Une troisième propriété nommée stack
existe à des fins de débogage, elle est supportée dans la majorité des environnements malgré son caractère non-standard. Nul besoin de s’en occuper, car elle est générée et exploitée automatiquement (pour les plus curieux, stack
est une simple string, aucune magie).
JavaScript fournit une classe Error
pour nous permettre de le générer facilement :
// Pseudocode pour la classe Error
class Error {
constructor(message) {
this.message = message;
this.name = "Error";
this.stack = <call stack>;
}
}
try {
throw new Error("ho non !") // génère un objet Error
} catch (err) {
alert(err.name); // "Error", de type string
alert(err.message); // "ho non !"
}
Note : new Error()
et Error()
font exactement la même chose.
Le type Error
étant très générique, il est difficile de savoir si l’erreur provient d’un problème réseau, d’une erreur utilisateur ou même d’une cause inconnue. Comment reconnaître les différents types d’erreurs ? En instanciant des objets Error
spécifiques selon les cas.
S'il faut retenir une seule chose de cet article : on peut étendre la classe qui nous a aidé à générer l’objet Error et ainsi débloquer de puissantes fonctionnalités. Un nommage de chaque type d’erreur pour mieux les identifier (HttpError, NotFoundError, TimeoutError, etc) et l’ajout d’autres propriétés pour nous aider à classer chaque type d’erreur (HttpError pourrait par exemple posséder une propriété statusCode pour stocker le code HTTP de la réponse).
Voici un exemple repris de l’excellent site https://javascript.info/custom-errors. Il est important que ce soit clair et il ne faut pas hésiter à mettre en pratique et expérimenter. C’est en plus un bon exercice si vous n’êtes pas à l’aise avec les classes en JavaScript.
// On étend la classe Error native
class ValidationError extends Error {
constructor(message) {
super(message);
this.name = "ValidationError";
}
}
// Fonction "lanceuse" qui utilise cette classe
function readUser(json) {
let user = JSON.parse(json);
if (!user.age) {
throw new ValidationError("No field: age");
}
if (!user.name) {
throw new ValidationError("No field: name");
}
return user;
}
// Code "receveur" qui identifie et trie les erreurs
try {
let user = readUser('{ "age": 25 }');
} catch (err) {
if (err instanceof ValidationError) { // mot-clé instanceof
alert("Invalid data: " + err.message);
} else if (err instanceof SyntaxError) { // Erreur native de JSON.parse
alert("JSON Syntax Error: " + err.message);
} else {
throw err; // erreur inconnue, on relance !
}
}
IMPORTANT : Le mot-clé instanceof est ici crucial pour l’identification des erreurs, c’est le moyen le plus simple et le plus pratique. Si vous ne l’utilisiez pas pour gérer les erreurs jusqu'à présent, faites-le !
TypeScript : un ami qui vous veut du bien
Avec TypeScript vous allez vous épargner et prévenir toutes les erreurs de typage (entre autres) qui sont extrêmement fréquentes en JavaScript, langage permissif au possible.
Toutes les erreurs que vous allez “subir” de manière statique — directement quand vous codez, dans votre IDE — seront autant d’erreurs que vous n’aurez pas à gérer au runtime, c'est-à-dire quand l’application exécute le code. Vous éviterez ainsi de faire subir à vos utilisateurs des erreurs imprévues (et n’oubliez pas : les utilisateurs, on les chouchoute !).
Intéressons-nous au type d’erreur capturée dans un try…catch
. Si vous ne le connaissez pas, essayez de deviner :
try {
throw Error()
} catch (err) { // <-- quel type ?
// gestion de l'erreur
}
C’est de type Error
? De type any
peut-être ? Il s’agit ici du type unknown
.
Éliminons Error
: à première vue, c'est logique. Littéralement une ligne au-dessus, nous avons throw nous-même une Error, de type Error.
Mais du point de vue de TypeScript, donc du point de vue d’un langage à typage statique, il ne sait pas “ce qui s’est passé en amont”. TypeScript n’analyse pas votre code de manière dynamique (d’où le terme statique).
Ensuite, éliminons any
: si vous avez bien lu le début de l’article, vous savez que les seuls types possibles sont : string
, number
, boolean
et Object
. Donc pas “any” car il est par exemple impossible de throw null
ou undefined
.
Mais surtout, la grande différence avec unknown est que any n’est pas type-safe : on peut faire absolument n’importe quoi avec. Par exemple, dans un bloc catch, on peut utiliser console.log(err.message) sans problème. En revanche, avec unknown, TypeScript affichera une erreur dans l’éditeur : 'err' is of type 'unknown' (exemple ici).
Ensuite, exactement comme en JavaScript, il suffit de tester notre erreur avec des conditions comme instanceof
(comme vu plus haut), typeof
, etc. Ce processus s’appelle le “narrowing”, son objectif est de réduire le champ des possibles : on part d’un objet inconnu, pour se retrouver avec un objet précis (exemple : unknown → ValidationError).
React : penser expérience utilisateur avant tout
Nous savons comment capturer les erreurs, mais que faire concrètement côté frontend ? Avant de passer à la technique, il est essentiel de réfléchir en amont aux scénarios possibles et à la manière de gérer les imprévus. D’expérience, cette phase est souvent à peine effleurée, voire complètement ignorée. La réflexion doit se faire avant de coder : que doit-on afficher si la connexion échoue ? Quel message transmettre ? Un bouton pour recharger toute la page, ou seulement une partie de celle-ci ? Afficher une alerte ‘Une erreur est survenue’ ?
De nombreuses questions doivent se poser et cela illustre bien que le travail se passe en amont. Car techniquement avec toutes les billes que vous avez en main, c’est relativement simple.
Depuis React 16, si on ne catch pas une erreur, tout l’arbre de composants React est retiré. L’application crashe. C’est une bonne décision puisqu'il vaut mieux une application plantée entièrement qu’une application à moitié debout et qui pourrait provoquer des bugs étranges, voire dangereux niveau sécurité.
Nous avons ici le degré zéro de la tolérance aux pannes. React nous fournit un outil pour améliorer cela : les errors boundaries (limites d’erreurs).
Le principe est simple : on entoure tout ou partie de notre arbre de composant d’un ou plusieurs errors boundaries
. Ainsi, quand une erreur est throw durant le rendu d’un composant, on peut afficher un message d’erreur plutôt que de voir toute l’application crasher. Si on reprend l’allusion au baseball, chaque ErrorBoundary est en fait un composant receveur. 🧢🏏
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}
// Obligatoire pour que ce soit reconnu comme error boundary
static getDerivedStateFromError(error) {
return { hasError: true };
}
render() {
if (this.state.hasError) {
return <div>Une erreur s'est produite ! 🙀</div>;
}
// Si pas d'erreur, rendu normal du reste de l'application
return this.props.children;
}
}
Pourquoi utilise-t-on un Class Component et pas un Function Component me demanderiez-vous ? Eh bien, je ne sais pas. Mais c’est comme ça ! Mon avis est que la team React a jugé plus simple de garder les lifecycles, propre aux class components, plutôt que de réimplémenter la logique avec des hooks. 🤷
Un class component devient une error boundary s'il définit au moins l’une des deux méthodes de lifecycle suivantes :
- static getDerivedStateFromError(error) : pour permettre d’afficher une UI de secours
- componentDidCatch(error, info) : pour log l’erreur (voir la fin de ce chapitre pour comprendre l’utilité)
Ensuite, créons un composant lanceur qui va planter à 100%. On peut throw facilement dans n’importe quel composant de cette façon :
function MonComposantQuiPlante() {
throw Error("Oops!... I Did It Again")
return <div>Cette div ne sera jamais rendue !</div>
}
On assemble le tout :
<ErrorBoundary>
<MonComposantQuiPlante />
</ErrorBoundary>
Le message “Une erreur s'est produite !” sera affiché. Voilà un codesandbox si vous voulez essayer et expérimenter.
Englober toute son application dans un seul ErrorBoundary est simple, mais nous n’avons pas augmenté réellement notre tolérance aux failles : une erreur à n'importe quel endroit entraîne un plantage complet de l’application, on a juste le contrôle sur ce qui sera rendu à ce moment-là. Mieux que rien, et cela peut être suffisant dans quelques cas.
L’idéal est d’identifier et de découper en parties distinctes notre application. La découpe et le nombre idéal d’ErrorBoundary peuvent varier d’un projet à un autre, mais pour donner un bon point de départ, il faut identifier les features principales et notamment celles qui interagissent fréquemment avec un serveur externe, source possible d'erreurs réseau.
Prenons comme exemple un site comme Reddit ou Mastodon, un utilisateur peut accéder à sa messagerie et voir la liste de tous ses messages. Si lors de la récupération des messages, une erreur réseau survient, il ne faut pas que tout plante, mais plutôt qu’un bouton apparaisse à la place de la liste permettant à l’utilisateur de rafraîchir celle-ci. Le layout global (header, footer, etc) du site reste intact, et l’erreur a été gérée élégamment.
<ErrorBoundary>
<Header />
<ErrorBoundary>
<Inbox />
</ErrorBoundary>
<Footer />
</ErrorBoundary>
Note sur le SSR : les Error Boundaries fonctionnent uniquement pour les erreurs côté client. Pour les erreurs côté serveur, notamment dans un contexte de Server-Side Rendering (SSR), vous pouvez utiliser des mécanismes comme <Suspense> pour gérer les erreurs survenant pendant le rendu ou lors de la récupération de données nécessaires à la page. Bien que leurs usages diffèrent, les Error Boundaries et <Suspense> peuvent être combinés pour offrir une gestion robuste des erreurs côté client et côté serveur.
Pour surveiller et diagnostiquer les erreurs dans une application React des outils comme Sentry, LogRocket ou Datadog permettent de capturer et d’enregistrer les erreurs, avec des détails précieux (stack trace, contexte utilisateur, etc.).
L'utilisation d'Error Boundaries facilite cette intégration en interceptant les erreurs dans le rendu et en les transmettant au service choisi grâce au lifecycle componentDidCatch :
class ErrorBoundary extends React.Component {
componentDidCatch(error, errorInfo) {
logToMonitorService(error, { extra: errorInfo });
}
render() {
if (this.state.hasError) {
return <p>Une erreur est survenue.</p>;
}
return this.props.children;
}
}
Conclusion
En résumé, maîtriser la gestion des erreurs repose avant tout sur une bonne compréhension des bases du langage JavaScript. Savoir comment les erreurs circulent dans le code, depuis leur déclenchement jusqu'à leur interception, est essentiel pour anticiper et résoudre efficacement les problèmes. Cette compréhension vous permettra non seulement d'écrire un code plus robuste, mais aussi de mieux collaborer avec votre équipe dans la conception d'applications fiables.
Côté frontend, la gestion des erreurs ne se limite pas à un aspect technique : elle reflète directement l'expérience utilisateur que vous souhaitez offrir. Un message d’erreur clair, une interface qui reste utilisable en cas de problème, ou une stratégie de repli intelligente peuvent transformer une situation frustrante en une expérience bien pensée. Après tout, bien gérer les erreurs, c’est avant tout prendre soin de vos utilisateurs.
Ne faites plus l’erreur, de ne plus gérer les erreurs !