Retour au cours

frontend / typescript

TypeScript avec React

Leçon 161 exercice

Explication

Ce que vous allez apprendre

  • Typer les props d'un composant avec une interface, et ReactNode pour les enfants
  • Typer useState avec une union explicite quand la valeur initiale démarre à null
  • Comprendre pourquoi useRef<HTMLInputElement>(null) inclut toujours null dans son type
  • Écrire un composant générique réutilisable pour n'importe quel type de données
  • Typer des props avec une union discriminée pour un narrowing automatique dans le JSX
  • Créer un hook personnalisé générique dont le retour est typé en tuple

Dans quel contexte ?

Une équipe migre progressivement un vieux composant Tableau.jsx vers Tableau.tsx. En JavaScript, la prop rows acceptait n'importe quoi, et un développeur avait fini par lui passer un objet unique au lieu d'un tableau — l'erreur n'était apparue qu'en production, sous la forme d'un message cryptique rows.map is not a function, plusieurs semaines plus tard. En TypeScript, cette même erreur de forme est détectée à la compilation, directement dans l'éditeur, avant même de lancer l'application une seule fois. C'est exactement l'objectif de cette leçon : transformer les erreurs runtime les plus fréquentes de React en simples erreurs de compilation.

Appliquer les acquis des leçons précédentes à un cas concret

Cette leçon ne présente aucune nouvelle notion de typage — elle applique tout ce que vous avez appris (interfaces, génériques, unions discriminées) au contexte très concret du développement avec React. C'est souvent ici que les développeurs comprennent enfin pourquoi apprendre TypeScript en profondeur était utile : les props, les hooks et les événements deviennent tous vérifiés à la compilation plutôt que découverts à l'exécution via des erreurs obscures dans la console.

Prérequis

Cette leçon suppose les interfaces, les génériques et les unions discriminées déjà acquis dans les leçons précédentes : elle ne fait que les appliquer au contexte de React, sans introduire de nouvelle notion de typage.

Props et enfants

Les props d'un composant se typent avec une interface classique. Le type ReactNode est spécial : il représente tout ce qui peut être rendu par React (texte, nombre, élément JSX, tableau d'éléments, ou rien du tout), et sert typiquement à typer la prop children.

Les hooks ont besoin d'un coup de pouce de typage

useState peut souvent déduire son type depuis la valeur initiale, mais quand cette valeur initiale est null, en attendant un chargement asynchrone par exemple, il faut préciser explicitement l'union attendue. useRef pointant vers un élément DOM commence toujours à null avant que le composant ne soit monté : c'est pourquoi son type inclut systématiquement null, et pourquoi on doit toujours vérifier .current avant de l'utiliser.

HookPoint de typage à retenir
useState(valeur)Le type est déduit automatiquement depuis la valeur initiale
useState<T | null>(null)Union explicite obligatoire quand la valeur initiale est null
useRef<HTMLInputElement>(null)Toujours accessible via .current, potentiellement null avant montage
useCallback(fn, deps)Le type de fn est déduit directement depuis sa propre signature

Piège fréquent

Oublier de vérifier inputRef.current avant de l'utiliser (inputRef.current?.focus()) provoque une erreur Cannot read properties of null : .current vaut toujours null avant que le composant soit monté, même si l'élément semble déjà visible à l'écran au moment où on lit le code.

Composants et hooks génériques

Un composant peut lui-même être générique, ce qui permet d'écrire une liste réutilisable pour n'importe quel type d'élément, avec le type déduit automatiquement depuis les données passées en props — un cas d'usage direct des génériques vus plus tôt dans le cours. Les unions discriminées, elles, permettent d'écrire des props qui changent de forme selon une variante, avec un narrowing automatique dans le JSX.

Commandes & code

TypeScript avec React

tsx
import { useState, useEffect, useRef, useCallback, type ReactNode } from "react";

// Props typées via interface, enfants typés avec ReactNode
interface CarteProps {
  titre: string;
  sousTitre?: string;
  enfants: ReactNode;
  onFermer?: () => void;
}

function Carte({ titre, sousTitre, enfants, onFermer }: CarteProps) {
  return (
    <div className="carte">
      <h2>{titre}</h2>
      {sousTitre && <p>{sousTitre}</p>}
      {enfants}
      {onFermer && <button onClick={onFermer}>Fermer</button>}
    </div>
  );
}

// useState typé : l'union inclut explicitement "null" quand pertinent
function ProfilUtilisateur({ id }: { id: number }) {
  const [utilisateur, setUtilisateur] = useState<{ nom: string; email: string } | null>(null);
  const [chargement, setChargement] = useState<boolean>(true);

  useEffect(() => {
    let annule = false;
    async function charger() {
      const reponse = await fetch(`/api/utilisateurs/${id}`);
      const donnees = await reponse.json();
      if (!annule) {
        setUtilisateur(donnees);
        setChargement(false);
      }
    }
    charger();
    return () => {
      annule = true; // évite un setState après démontage
    };
  }, [id]);

  if (chargement) return <p>Chargement...</p>;
  return utilisateur ? <p>{utilisateur.nom}</p> : <p>Introuvable</p>;
}

// useRef typé sur un élément DOM : current peut être null avant le montage
function ChampFocus() {
  const inputRef = useRef<HTMLInputElement>(null);
  useEffect(() => {
    inputRef.current?.focus();
  }, []);
  return <input ref={inputRef} />;
}

// Composant générique : réutilisable pour n'importe quel type d'élément de liste
interface ListeProps<T> {
  items: T[];
  renduItem: (item: T) => ReactNode;
  cleItem: (item: T) => string | number;
}

function Liste<T>({ items, renduItem, cleItem }: ListeProps<T>) {
  return (
    <ul>
      {items.map((item) => (
        <li key={cleItem(item)}>{renduItem(item)}</li>
      ))}
    </ul>
  );
}
// <Liste items={produits} cleItem={(p) => p.id} renduItem={(p) => <span>{p.nom}</span>} />
// Le type <Produit> est inféré automatiquement depuis "items"

// Hook personnalisé typé avec générique, retour typé en tuple
function useLocalStorage<T>(cle: string, valeurInitiale: T): [T, (v: T) => void] {
  const [valeur, setValeur] = useState<T>(() => {
    const stocke = localStorage.getItem(cle);
    return stocke ? (JSON.parse(stocke) as T) : valeurInitiale;
  });

  const set = useCallback(
    (nouvelleValeur: T) => {
      setValeur(nouvelleValeur);
      localStorage.setItem(cle, JSON.stringify(nouvelleValeur));
    },
    [cle]
  );

  return [valeur, set];
}

// Props avec union discriminée : TS narrow automatiquement selon "variante"
type ProprietesAlerte =
  | { variante: "succes"; message: string }
  | { variante: "erreur"; message: string; codeErreur: number };

function Alerte(props: ProprietesAlerte) {
  if (props.variante === "erreur") {
    return (
      <div className="erreur">
        {props.message} (code {props.codeErreur})
      </div>
    );
  }
  return <div className="succes">{props.message}</div>;
}

Résumé

  • ReactNode type tout ce qui est rendable en JSX (children compris).
  • useRef<T>(null) produit un RefObject<T | null> : toujours vérifier .current.
  • Un composant générique (Liste<T>) infère T depuis les props passées à l'usage.
  • Les unions discriminées en props permettent un narrowing direct dans le JSX.

Exercices pratiques

1 disponible
1

Mission : réparer Tableau.tsx après la migration depuis Tableau.jsx

Objectif : Typer correctement les props d'un composant migré depuis JavaScript pour transformer une erreur runtime en erreur de compilation.

Contexte

Une équipe migre Tableau.jsx vers Tableau.tsx. En JavaScript, la prop rows acceptait n'importe quoi, et un objet unique avait fini par y être passé au lieu d'un tableau : l'erreur rows.map is not a function n'est apparue qu'en production, des semaines plus tard. Ta mission : typer ce composant pour rendre cette erreur impossible à compiler.

Résoudre l’exercice →