TypeScript et React#

Hide code cell source

import matplotlib.pyplot as plt
import matplotlib.patches as patches
import numpy as np
import seaborn as sns

sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)

React et TypeScript forment aujourd’hui l’un des duos les plus répandus dans le développement web frontal. React apporte un modèle de composants déclaratifs et réactifs ; TypeScript y ajoute la sécurité du typage statique, rendant les props vérifiées à la compilation, les hooks correctement inférés et les erreurs de types détectées bien avant l’exécution dans le navigateur. Ce chapitre guide vers une utilisation idiomatique et rigoureuse de TypeScript dans une application React.

Mise en place#

La façon la plus directe de démarrer un projet React + TypeScript en 2024 est d’utiliser Vite, un outil de build rapide qui propose un template dédié :

npm create vite@latest mon-app -- --template react-ts
cd mon-app
npm install
npm run dev

Cette commande génère une arborescence avec un tsconfig.json déjà configuré pour React. L’option la plus importante dans ce fichier est l’option jsx du compilateur.

{
  "compilerOptions": {
    "target": "ES2020",
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "moduleResolution": "bundler",
    "jsx": "react-jsx",
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true
  }
}

```{prf:definition} L’option jsx du compilateur :label: definition-13-01 L’option jsx contrôle la façon dont TypeScript transforme la syntaxe JSX. La valeur "react-jsx" utilise la nouvelle transformation JSX introduite dans React 17 : le compilateur insère automatiquement l’import de react/jsx-runtime, sans qu’il soit nécessaire d’écrire import React from 'react' dans chaque fichier. La valeur historique "react" requiert cet import explicite. Pour un projet Vite, "react-jsx" est systématiquement recommandée.


Les types React sont fournis par le paquet `@types/react`, installé automatiquement avec le template Vite. Ce paquet expose toutes les interfaces — `React.FC`, `React.ReactNode`, `React.MouseEvent`, etc. — que l'on va utiliser tout au long de ce chapitre.

## Typer les composants fonctionnels

Un composant React est une fonction qui accepte des props et retourne du JSX. TypeScript doit donc typer à la fois les arguments et la valeur de retour.

### `React.FC<Props>` versus signature directe

Il existe deux conventions pour annoter un composant fonctionnel. La première est d'utiliser le type `React.FC<Props>` (ou `React.FunctionComponent<Props>`) :

```typescript
import React from 'react';

interface SalutationProps {
  nom: string;
  age?: number;
}

// Approche avec React.FC
const Salutation: React.FC<SalutationProps> = ({ nom, age }) => {
  return (
    <p>
      Bonjour, {nom}{age !== undefined ? ` (${age} ans)` : ''} !
    </p>
  );
};

La seconde approche, aujourd’hui préférée par la majorité de la communauté, consiste à annoter directement le paramètre de la fonction :

// Approche par signature directe (recommandée)
const Salutation = ({ nom, age }: SalutationProps): React.ReactElement => {
  return (
    <p>
      Bonjour, {nom}{age !== undefined ? ` (${age} ans)` : ''} !
    </p>
  );
};

Remarque 30

Pourquoi éviter React.FC ? Historiquement, React.FC ajoutait automatiquement children aux props, même lorsqu’un composant ne les acceptait pas — ce comportement a été retiré dans React 18, mais la méfiance reste justifiée. De plus, React.FC interdit certaines syntaxes comme les composants génériques (const Composant: React.FC<Props<T>> = ... ne fonctionne pas avec un paramètre de type générique). La signature directe est plus explicite, plus flexible et se comporte exactement comme on l’attend.

React.ReactNode versus React.PropsWithChildren#

Lorsqu’un composant accepte des enfants, deux approches sont courantes :

// Option 1 : déclarer children explicitement avec ReactNode
interface PanneauProps {
  titre: string;
  children: React.ReactNode;
}

// Option 2 : utiliser PropsWithChildren (sucre syntaxique)
type PanneauProps = React.PropsWithChildren<{
  titre: string;
}>;

const Panneau = ({ titre, children }: PanneauProps) => (
  <div className="panneau">
    <h2>{titre}</h2>
    <div>{children}</div>
  </div>
);

React.ReactNode est le type le plus large qui représente tout ce qu’on peut rendre : un élément JSX, une chaîne, un nombre, un tableau, null, undefined ou un booléen. React.ReactElement est plus étroit et exclut null et les primitives — c’est le type d’une expression JSX concrète.

Typer les props#

Interface de props et valeurs optionnelles#

Une bonne interface de props est explicite sur ce qui est requis et ce qui est optionnel, et documente les propriétés avec des commentaires JSDoc :

interface BoutonProps {
  /** Le texte affiché dans le bouton. */
  libellé: string;
  /** La variante visuelle du bouton. */
  variante?: 'primaire' | 'secondaire' | 'danger';
  /** Désactive le bouton lorsque `true`. */
  désactivé?: boolean;
  /** Appelé lorsque le bouton est cliqué. */
  onClick?: (événement: React.MouseEvent<HTMLButtonElement>) => void;
}

const Bouton = ({
  libellé,
  variante = 'primaire',
  désactivé = false,
  onClick,
}: BoutonProps) => (
  <button
    className={`bouton bouton--${variante}`}
    disabled={désactivé}
    onClick={onClick}
  >
    {libellé}
  </button>
);

Typer les événements#

React enveloppe les événements natifs dans des types synthétiques. Les types les plus utiles sont :

// Événement de clic sur un élément button
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
  e.preventDefault();
  console.log(e.currentTarget.name);
};

// Événement de changement sur un input texte
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  console.log(e.target.value);
};

// Événement de soumission de formulaire
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
  e.preventDefault();
};

// Événement clavier
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
  if (e.key === 'Enter') { /* ... */ }
};

React.ComponentProps pour étendre des éléments natifs#

Lorsqu’on crée un composant qui encapsule un élément HTML natif, React.ComponentProps permet d’hériter de tous ses attributs sans les redéclarer :

// Étendre les props natives du bouton HTML
type BoutonProps = React.ComponentProps<'button'> & {
  variante?: 'primaire' | 'secondaire';
};

const Bouton = ({ variante = 'primaire', className = '', ...rest }: BoutonProps) => (
  <button
    className={`bouton bouton--${variante} ${className}`}
    {...rest}
  />
);

// Utilisation : toutes les props HTML button sont disponibles
<Bouton type="submit" disabled aria-label="Valider" variante="primaire">
  Valider
</Bouton>

Hooks typés#

useState<T>#

TypeScript infère généralement le type de useState à partir de la valeur initiale. Une annotation explicite devient nécessaire quand l’état peut être null ou undefined, ou quand la valeur initiale ne représente pas fidèlement la forme finale de l’état :

// Inférence automatique : état de type string
const [nom, setNom] = useState('');

// Annotation explicite : état de type number | null
const [score, setScore] = useState<number | null>(null);

// Annotation explicite : état d'un objet complexe
interface Utilisateur {
  id: number;
  nom: string;
  email: string;
}

const [utilisateur, setUtilisateur] = useState<Utilisateur | null>(null);

useRef<T>#

useRef a deux cas d’usage distincts, et leurs types diffèrent.

// Cas 1 : référence vers un élément DOM (valeur initiale null)
// Le type est React.RefObject<HTMLInputElement> — la ref ne peut pas être muée
const inputRef = useRef<HTMLInputElement>(null);

useEffect(() => {
  inputRef.current?.focus();
}, []);

// Cas 2 : conteneur mutable (valeur initiale fournie)
// Le type est React.MutableRefObject<number>
const compteurRef = useRef<number>(0);
compteurRef.current += 1;

Remarque 31

La distinction est importante : lorsque la valeur initiale est null et que le type générique est un élément DOM (useRef<HTMLDivElement>(null)), TypeScript retourne un RefObject<T> dont la propriété current est en lecture seule. Cela reflète le fait que c’est React qui attache et détache la référence DOM. En revanche, useRef<number>(0) retourne un MutableRefObject<number> avec un current librement modifiable.

useReducer avec des unions discriminées#

useReducer tire tout son potentiel de TypeScript lorsque l’action est modélisée par une union discriminée :

interface État {
  compteur: number;
  chargement: boolean;
  erreur: string | null;
}

type Action =
  | { type: 'INCRÉMENTER'; payload: number }
  | { type: 'RÉINITIALISER' }
  | { type: 'CHARGEMENT_DÉBUT' }
  | { type: 'CHARGEMENT_FIN'; erreur?: string };

function réducteur(état: État, action: Action): État {
  switch (action.type) {
    case 'INCRÉMENTER':
      // TypeScript sait que action.payload est un number ici
      return { ...état, compteur: état.compteur + action.payload };
    case 'RÉINITIALISER':
      return { compteur: 0, chargement: false, erreur: null };
    case 'CHARGEMENT_DÉBUT':
      return { ...état, chargement: true, erreur: null };
    case 'CHARGEMENT_FIN':
      return { ...état, chargement: false, erreur: action.erreur ?? null };
  }
}

const [état, dispatch] = useReducer(réducteur, {
  compteur: 0,
  chargement: false,
  erreur: null,
});

useContext bien typé#

Le contexte React doit être typé dès sa création pour que les consommateurs bénéficient d’un type précis :

interface ThèmeContextType {
  thème: 'clair' | 'sombre';
  basculer: () => void;
}

// Créer le contexte avec une valeur par défaut typée
const ThèmeContext = React.createContext<ThèmeContextType | null>(null);

// Hook personnalisé pour garantir que le contexte est utilisé dans le bon fournisseur
function useThème(): ThèmeContextType {
  const contexte = React.useContext(ThèmeContext);
  if (contexte === null) {
    throw new Error('useThème doit être utilisé à l\'intérieur de ThèmeFournisseur');
  }
  return contexte;
}

// Fournisseur
function ThèmeFournisseur({ children }: React.PropsWithChildren) {
  const [thème, setThème] = React.useState<'clair' | 'sombre'>('clair');
  const basculer = () => setThème(t => (t === 'clair' ? 'sombre' : 'clair'));
  return (
    <ThèmeContext.Provider value={{ thème, basculer }}>
      {children}
    </ThèmeContext.Provider>
  );
}

Composants génériques#

Un composant List<T> générique#

Un composant générique accepte un paramètre de type, ce qui permet de réutiliser la même logique pour différentes formes de données tout en conservant la sécurité du typage :

interface ListeProps<T> {
  éléments: T[];
  renduÉlément: (élément: T, index: number) => React.ReactNode;
  clé: (élément: T) => string | number;
}

// La syntaxe <T,> évite l'ambiguïté avec le JSX dans les fichiers .tsx
function Liste<T>({ éléments, renduÉlément, clé }: ListeProps<T>) {
  return (
    <ul>
      {éléments.map((élément, index) => (
        <li key={clé(élément)}>{renduÉlément(élément, index)}</li>
      ))}
    </ul>
  );
}

// Utilisation avec inférence du type T = Utilisateur
<Liste
  éléments={utilisateurs}
  clé={u => u.id}
  renduÉlément={u => <span>{u.nom}</span>}
/>

forwardRef avec des génériques#

forwardRef est nécessaire lorsqu’un composant doit transmettre une référence DOM à son élément interne. Depuis React 19, ref est passée directement comme prop ; pour React 18, la syntaxe est :

interface InputProps extends React.ComponentProps<'input'> {
  libellé: string;
}

const InputAvecLibellé = React.forwardRef<HTMLInputElement, InputProps>(
  ({ libellé, ...rest }, ref) => (
    <div>
      <label>{libellé}</label>
      <input ref={ref} {...rest} />
    </div>
  )
);

InputAvecLibellé.displayName = 'InputAvecLibellé';

Portails, erreurs et Suspense#

ErrorBoundary typé#

Les ErrorBoundary sont nécessairement des composants de classe car ils utilisent componentDidCatch. TypeScript exige d’annoter explicitement l’état :

interface ÉtatErreur {
  aUneErreur: boolean;
  erreur: Error | null;
}

interface PropsErreur {
  fallback: React.ReactNode;
  children: React.ReactNode;
}

class FrontièreErreur extends React.Component<PropsErreur, ÉtatErreur> {
  constructor(props: PropsErreur) {
    super(props);
    this.state = { aUneErreur: false, erreur: null };
  }

  static getDerivedStateFromError(erreur: Error): ÉtatErreur {
    return { aUneErreur: true, erreur };
  }

  componentDidCatch(erreur: Error, info: React.ErrorInfo) {
    console.error('Erreur capturée :', erreur, info.componentStack);
  }

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

React.lazy et Suspense#

// Le composant chargé paresseusement doit exporter un composant par défaut
const TableauDeBord = React.lazy(
  () => import('./TableauDeBord')
);

function App() {
  return (
    <FrontièreErreur fallback={<p>Une erreur est survenue.</p>}>
      <React.Suspense fallback={<p>Chargement</p>}>
        <TableauDeBord />
      </React.Suspense>
    </FrontièreErreur>
  );
}

Exemple 15 (Flux de données typé dans une application React)

Dans une application React typée, chaque transfert d’information entre composants est vérifié statiquement. Le composant parent transmet des props dont l’interface est déclarée ; le composant enfant reçoit ces données avec un type connu. Lorsque l’enfant doit remonter une information (événement utilisateur, changement d’état), il appelle une fonction de rappel dont la signature est également typée. Ce circuit fermé — props typées vers le bas, callbacks typés vers le haut — est la garantie centrale que React et TypeScript offrent conjointement.

Hide code cell source

fig, ax = plt.subplots(figsize=(14, 8))
ax.set_xlim(-0.5, 14)
ax.set_ylim(-1, 9)
ax.axis('off')
ax.set_title(
    'Flux de données typé dans une application React',
    fontsize=15, fontweight='bold', pad=18
)

# Couleurs
c_parent  = '#2980b9'
c_enfant1 = '#27ae60'
c_enfant2 = '#8e44ad'
c_hook    = '#e67e22'
c_arrow   = '#2c3e50'
c_back    = '#c0392b'

def boite(ax, x, y, w, h, couleur, titre, sous_titre='', alpha=0.15):
    rect = patches.FancyBboxPatch(
        (x, y), w, h,
        boxstyle='round,pad=0.15', linewidth=2.2,
        edgecolor=couleur, facecolor=couleur, alpha=alpha
    )
    ax.add_patch(rect)
    bordure = patches.FancyBboxPatch(
        (x, y), w, h,
        boxstyle='round,pad=0.15', linewidth=2.2,
        edgecolor=couleur, facecolor='none'
    )
    ax.add_patch(bordure)
    ax.text(x + w / 2, y + h - 0.45, titre,
            ha='center', va='center', fontsize=11,
            fontweight='bold', color=couleur)
    if sous_titre:
        ax.text(x + w / 2, y + 0.5, sous_titre,
                ha='center', va='center', fontsize=8,
                color='#555555', style='italic')

# Composant parent
boite(ax, 1.0, 5.5, 5.5, 2.8, c_parent,
      'Composant Parent',
      'état local · useReducer · useContext', alpha=0.12)

# Hook store (à droite du parent)
boite(ax, 7.5, 5.5, 5.0, 2.8, c_hook,
      'Hooks / Store',
      'useState<T> · useRef<T> · useReducer', alpha=0.12)

# Composant enfant 1
boite(ax, 0.5, 1.5, 5.5, 2.8, c_enfant1,
      'Composant Enfant A',
      'props typées · React.MouseEvent', alpha=0.12)

# Composant enfant 2
boite(ax, 7.0, 1.5, 6.0, 2.8, c_enfant2,
      'Composant Enfant B',
      'props typées · React.ChangeEvent', alpha=0.12)

# Flèche parent → enfant A (props)
ax.annotate('',
    xy=(3.25, 1.5 + 2.8),
    xytext=(3.25, 5.5),
    arrowprops=dict(arrowstyle='->', color=c_parent, lw=2.2))
ax.text(3.25, 4.6, 'props typées\n(interface Props)',
        ha='center', va='center', fontsize=8,
        color=c_parent, fontweight='bold',
        bbox=dict(boxstyle='round,pad=0.2', facecolor='white',
                  edgecolor=c_parent, alpha=0.85))

# Flèche parent → enfant B (props)
ax.annotate('',
    xy=(10.0, 1.5 + 2.8),
    xytext=(10.0, 5.5),
    arrowprops=dict(arrowstyle='->', color=c_parent, lw=2.2))
ax.text(10.0, 4.6, 'props typées\n(interface Props)',
        ha='center', va='center', fontsize=8,
        color=c_parent, fontweight='bold',
        bbox=dict(boxstyle='round,pad=0.2', facecolor='white',
                  edgecolor=c_parent, alpha=0.85))

# Flèche enfant A → parent (callback)
ax.annotate('',
    xy=(2.0, 5.5),
    xytext=(2.0, 1.5 + 2.8),
    arrowprops=dict(arrowstyle='->', color=c_back, lw=2.0,
                    linestyle='dashed'))
ax.text(1.0, 4.3, 'callback typé\nonChange / onClick',
        ha='center', va='center', fontsize=7.5,
        color=c_back, fontweight='bold',
        bbox=dict(boxstyle='round,pad=0.2', facecolor='white',
                  edgecolor=c_back, alpha=0.85))

# Flèche enfant B → parent (callback)
ax.annotate('',
    xy=(9.0, 5.5),
    xytext=(9.0, 1.5 + 2.8),
    arrowprops=dict(arrowstyle='->', color=c_back, lw=2.0,
                    linestyle='dashed'))
ax.text(8.2, 4.3, 'callback typé\nonSubmit',
        ha='center', va='center', fontsize=7.5,
        color=c_back, fontweight='bold',
        bbox=dict(boxstyle='round,pad=0.2', facecolor='white',
                  edgecolor=c_back, alpha=0.85))

# Flèche parent ↔ hook store
ax.annotate('',
    xy=(7.5, 5.5 + 1.4),
    xytext=(6.5, 5.5 + 1.4),
    arrowprops=dict(arrowstyle='->', color=c_hook, lw=2.0))
ax.annotate('',
    xy=(6.5, 5.5 + 0.9),
    xytext=(7.5, 5.5 + 0.9),
    arrowprops=dict(arrowstyle='->', color=c_hook, lw=2.0))
ax.text(7.0, 5.5 + 1.8, 'dispatch / setState',
        ha='center', va='center', fontsize=7.5,
        color=c_hook, fontweight='bold')

# Légende
légende = [
    (c_parent, 'Composant parent (source des props)'),
    (c_enfant1, 'Composant enfant A'),
    (c_enfant2, 'Composant enfant B'),
    (c_hook,   'Hooks / gestion d\'état'),
    (c_back,   'Remontée via callbacks typés'),
]
for i, (couleur, texte) in enumerate(légende):
    ax.plot([0.5], [0.8 - i * 0.0], marker='s', color=couleur, markersize=8)

ax.text(0.5, 0.5, '  '.join(
    [f'■ {t}' for _, t in légende[:3]]
), ha='left', va='center', fontsize=7.5, color='#444444')
ax.text(0.5, 0.0, '  '.join(
    [f'■ {t}' for _, t in légende[3:]]
), ha='left', va='center', fontsize=7.5, color='#444444')

plt.tight_layout()
plt.show()
_images/d2c242ca89e94b5295af6df41a731a98fb59d372fa3ef30df3039cab98262a46.png

Résumé#

Ce chapitre a présenté l’intégration de TypeScript dans un projet React, des fondations de la configuration jusqu’aux patterns avancés :

  • Mise en place : Vite + React + TypeScript est la combinaison de départ recommandée. L’option jsx: "react-jsx" évite les imports manuels de React.

  • Composants fonctionnels : la signature directe ({ prop }: Props): ReactElement est préférée à React.FC<Props>, plus rigide et historiquement problématique.

  • Props et événements : chaque prop est décrite dans une interface ; les événements utilisent React.MouseEvent<T>, React.ChangeEvent<T>, etc. React.ComponentProps<'button'> permet d’hériter des attributs HTML natifs.

  • Hooks typés : useState<T> s’infère souvent ; useRef<T>(null) retourne une référence en lecture seule pour le DOM ; useReducer combiné à des unions discriminées exprime des machines à états robustes ; useContext se complète par un hook personnalisé qui lève une erreur si le contexte est absent.

  • Composants génériques : la syntaxe function Liste<T,>(...) permet de créer des composants réutilisables sans sacrifier la sécurité des types.

Le chapitre suivant explore l’intégration équivalente avec Vue 3, dont la Composition API et <script setup lang="ts"> offrent une expérience TypeScript tout aussi riche.