Guide complet pour typer vos composants React avec TypeScript : props, hooks, events, generics et patterns avancés pour débutants et juniors.
Pourquoi TypeScript avec React ?
TypeScript est devenu le standard de facto pour les projets React professionnels. En 2024, plus de 80% des nouveaux projets React sont initialisés avec TypeScript. Pourquoi cet engouement ?
La réponse tient en trois bénéfices concrets :
- Autocomplétion intelligente : votre IDE sait exactement quelles props sont disponibles, quels types sont attendus, quelles méthodes existent
- Erreurs détectées à la compilation : passer un
numberlà où unestringest attendue est une erreur capturée avant même d'ouvrir le navigateur - Documentation vivante : les types servent de contrat entre les composants — plus besoin de lire la doc pour savoir ce qu'accepte un composant
// Sans TypeScript : aucune aide de l'IDE, erreurs silencieuses
function CarteUtilisateur({ user, onDelete }) {
// On ne sait pas : user a-t-il un champ "name" ou "nom" ?
// onDelete reçoit-il l'id ou l'objet entier ?
return <div>{user.name}</div>;
}
// Avec TypeScript : contrat explicite, autocomplétion complète
interface Utilisateur {
id: number;
nom: string;
email: string;
actif: boolean;
}
interface CarteUtilisateurProps {
user: Utilisateur;
onDelete: (id: number) => void;
}
function CarteUtilisateur({ user, onDelete }: CarteUtilisateurProps) {
// IDE sait que user.nom existe, que onDelete attend un number
return (
<div>
<p>{user.nom}</p>
<button onClick={() => onDelete(user.id)}>Supprimer</button>
</div>
);
}
npm create vite@latest mon-app -- --template react-tsVite configure automatiquement TypeScript, les types React et le tsconfig optimisé.
| Situation | Sans TypeScript | Avec TypeScript |
|---|---|---|
| Prop manquante | Erreur silencieuse en runtime | Erreur rouge dans l'IDE |
| Mauvais type de prop | Bug difficile à tracer | Détecté à la compilation |
| Refactoring de prop | Chercher manuellement tous les usages | TS signale tous les endroits à corriger |
| Onboarding nouveau dev | Lire la doc ou le code source | Les types documentent le contrat |
Typer les props d'un composant
Il existe deux syntaxes pour définir le type des props : interface et type. Les deux fonctionnent, mais la convention React préfère interface pour les props (extensible) et type pour les unions et types composés.
Avec interface (recommandé pour les props)
// Convention : suffixe "Props" pour les interfaces de props
interface BoutonProps {
label: string; // Requis, chaîne de caractères
variante: 'primary' | 'secondary' | 'danger'; // Union littérale
taille: 'sm' | 'md' | 'lg';
disabled?: boolean; // Optionnel (le ? signifie que c'est facultatif)
onClick: () => void; // Fonction sans argument, sans retour
}
function Bouton({ label, variante, taille, disabled = false, onClick }: BoutonProps) {
// disabled a une valeur par défaut : false si non fourni
const classes = `btn btn-${variante} btn-${taille}`;
return (
<button className={classes} disabled={disabled} onClick={onClick}>
{label}
</button>
);
}
// Utilisation — TypeScript valide les props à la compilation
<Bouton
label="Enregistrer"
variante="primary"
taille="md"
onClick={() => console.log('clic')}
/>
// ❌ Erreur TypeScript : "xl" n'est pas dans 'sm' | 'md' | 'lg'
<Bouton label="Test" variante="primary" taille="xl" onClick={() => {}} />
Avec type (pour les unions et cas complexes)
// type est plus flexible pour les unions discriminées
type StatutCommande =
| { statut: 'en_attente' }
| { statut: 'expedie'; transporteur: string; numeroSuivi: string }
| { statut: 'livre'; datelivraison: Date }
| { statut: 'annule'; raisonAnnulation: string };
// TypeScript sait exactement quels champs sont disponibles selon le statut
function BadgeStatut({ statut }: { statut: StatutCommande }) {
switch (statut.statut) {
case 'expedie':
// Ici TypeScript sait que transporteur et numeroSuivi existent
return <span>Expédié par {statut.transporteur} — {statut.numeroSuivi}</span>;
case 'livre':
// Ici TypeScript sait que datelivraison existe
return <span>Livré le {statut.datelivraison.toLocaleDateString()}</span>;
case 'annule':
return <span>Annulé : {statut.raisonAnnulation}</span>;
default:
return <span>En attente</span>;
}
}
Étendre une interface existante
// Interface de base pour tous les éléments de formulaire
interface ChampBaseProps {
id: string;
label: string;
erreur?: string;
obligatoire?: boolean;
}
// Étendre pour un input texte — hérite de toutes les props de base
// HTMLInputElement donne accès à toutes les props natives de <input>
interface ChampTexteProps extends ChampBaseProps,
Omit<React.InputHTMLAttributes<HTMLInputElement>, 'id'> {
type?: 'text' | 'email' | 'password' | 'tel';
}
function ChampTexte({ id, label, erreur, obligatoire, type = 'text', ...reste }: ChampTexteProps) {
return (
<div className="mb-3">
<label htmlFor={id}>
{label}
{obligatoire && <span aria-hidden="true"> *</span>}
</label>
<input
id={id}
type={type}
className={`form-control ${erreur ? 'is-invalid' : ''}`}
aria-invalid={!!erreur}
aria-describedby={erreur ? `${id}-erreur` : undefined}
{...reste} // Toutes les props HTML natives (value, onChange, placeholder…)
/>
{erreur && (
<div id={`${id}-erreur`} className="invalid-feedback">
{erreur}
</div>
)}
</div>
);
}
Props avancées : children, callbacks, optionnelles
Typer children correctement
import { ReactNode, PropsWithChildren } from 'react';
// Option 1 : ReactNode (le plus permissif — accepte tout ce que React peut rendre)
interface CarteProps {
titre: string;
children: ReactNode; // string, JSX, tableau, null, undefined...
}
// Option 2 : PropsWithChildren (raccourci pratique)
// Équivalent à { children?: ReactNode } + vos props
function Carte({ titre, children }: PropsWithChildren<{ titre: string }>) {
return (
<div className="card">
<div className="card-header"><h3>{titre}</h3></div>
<div className="card-body">{children}</div>
</div>
);
}
// Option 3 : ReactElement (uniquement des éléments JSX, pas de string)
interface LayoutProps {
header: React.ReactElement; // Doit être un composant JSX
sidebar: React.ReactElement;
main: React.ReactElement;
}
Typer les fonctions de callback
// Différents types de callbacks selon le besoin
interface TableauProps<T> {
items: T[];
// Callback sans retour
onSelect: (item: T) => void;
// Callback avec retour booléen
onDelete: (id: number) => boolean;
// Callback asynchrone
onSave: (item: T) => Promise<void>;
// Callback optionnel
onSort?: (colonne: keyof T, ordre: 'asc' | 'desc') => void;
// Render prop : fonction qui retourne du JSX
renderItem: (item: T, index: number) => React.ReactNode;
}
// Exemple d'utilisation avec un render prop
function Liste<T extends { id: number }>({ items, renderItem }: TableauProps<T>) {
return (
<ul>
{items.map((item, index) => (
<li key={item.id}>{renderItem(item, index)}</li>
))}
</ul>
);
}
Props discriminées selon un type
// Pattern : props différentes selon une prop "variante"
// Évite de rendre toutes les props optionnelles (moins sûr)
type AlerteProps =
| {
type: 'succes';
message: string;
// Pas de prop "details" pour les succès
}
| {
type: 'erreur';
message: string;
details: string; // Obligatoire uniquement pour les erreurs
onReessayer?: () => void;
}
| {
type: 'info';
message: string;
lien?: string; // Optionnel uniquement pour les infos
};
function Alerte(props: AlerteProps) {
const classes = {
succes: 'alert alert-success',
erreur: 'alert alert-danger',
info: 'alert alert-info',
}[props.type];
return (
<div className={classes} role="alert">
<p>{props.message}</p>
{/* TypeScript sait que details n'existe que pour type='erreur' */}
{props.type === 'erreur' && <small>{props.details}</small>}
{props.type === 'erreur' && props.onReessayer && (
<button onClick={props.onReessayer}>Réessayer</button>
)}
</div>
);
}
Typer useState, useRef et useReducer
useState avec inférence automatique
import { useState } from 'react';
// TypeScript infère le type depuis la valeur initiale
const [compteur, setCompteur] = useState(0); // number
const [nom, setNom] = useState(''); // string
const [actif, setActif] = useState(false); // boolean
// Pour les types complexes, fournir le type générique explicitement
interface Utilisateur {
id: number;
nom: string;
email: string;
}
// Sans annotation : TypeScript infère Utilisateur | null
const [user, setUser] = useState<Utilisateur | null>(null);
// Avec assertion : si vous êtes sûr que user ne sera jamais null après init
// (à utiliser avec précaution)
const [config, setConfig] = useState<Utilisateur>({} as Utilisateur);
// Tableau typé
const [articles, setArticles] = useState<Utilisateur[]>([]);
// Équivalent : useState([] as Utilisateur[])
useRef : deux usages, deux types
import { useRef } from 'react';
// Usage 1 : référencer un élément DOM
// HTMLInputElement, HTMLDivElement, HTMLButtonElement, HTMLFormElement...
function ChampRecherche() {
// null comme valeur initiale = élément pas encore monté
const inputRef = useRef<HTMLInputElement>(null);
const focuser = () => {
// inputRef.current peut être null avant le montage
// L'opérateur ?. gère ce cas proprement
inputRef.current?.focus();
inputRef.current?.select(); // Sélectionner tout le texte
};
return (
<div>
{/* ref= attend RefObject<HTMLInputElement> — types compatibles */}
<input ref={inputRef} type="text" placeholder="Rechercher..." />
<button onClick={focuser}>Focuser</button>
</div>
);
}
// Usage 2 : valeur mutable qui ne déclenche pas de re-rendu
function CompteurRendus() {
const [etat, setEtat] = useState(0);
// MutableRefObject<number> — pas de null car valeur initiale fournie
const rendus = useRef<number>(0);
rendus.current += 1; // Incrémenter sans déclencher de re-rendu
return (
<div>
<p>État : {etat} — Rendus totaux : {rendus.current}</p>
<button onClick={() => setEtat(e => e + 1)}>Incrémenter</button>
</div>
);
}
useReducer avec types stricts
import { useReducer } from 'react';
// Typer l'état
interface EtatPanier {
items: ProduitPanier[];
promo: string | null;
chargement: boolean;
}
// Union discriminée pour les actions — pattern recommandé
type ActionPanier =
| { type: 'AJOUTER_ITEM'; payload: ProduitPanier }
| { type: 'RETIRER_ITEM'; payload: number } // id du produit
| { type: 'APPLIQUER_PROMO'; payload: string }
| { type: 'RETIRER_PROMO' } // pas de payload
| { type: 'VIDER' };
interface ProduitPanier {
id: number;
nom: string;
prix: number;
quantite: number;
}
// Le reducer est fortement typé — TypeScript vérifie chaque case
function panierReducer(etat: EtatPanier, action: ActionPanier): EtatPanier {
switch (action.type) {
case 'AJOUTER_ITEM':
// TypeScript sait que action.payload est ProduitPanier
return { ...etat, items: [...etat.items, action.payload] };
case 'RETIRER_ITEM':
// TypeScript sait que action.payload est number (l'id)
return { ...etat, items: etat.items.filter(i => i.id !== action.payload) };
case 'APPLIQUER_PROMO':
return { ...etat, promo: action.payload };
case 'RETIRER_PROMO':
// Pas de payload — TypeScript vérifie qu'on n'en accède pas
return { ...etat, promo: null };
case 'VIDER':
return { ...etat, items: [], promo: null };
default:
// Exhaustivité vérifiée par TypeScript
const _exhaustif: never = action;
return etat;
}
}
function Panier() {
const [etat, dispatch] = useReducer(panierReducer, {
items: [],
promo: null,
chargement: false,
});
return (
<div>
<p>{etat.items.length} article(s)</p>
<button onClick={() => dispatch({ type: 'VIDER' })}>
Vider
</button>
</div>
);
}
default: const _exhaustif: never = action force TypeScript à vérifier que tous les types d'action sont gérés. Si vous ajoutez un nouveau type d'action sans l'ajouter au switch, TypeScript signale une erreur.
Typer les événements DOM
Les événements React sont génériques. Chaque type d'événement a son propre type TypeScript. Voici le guide complet des événements les plus courants.
import { ChangeEvent, FormEvent, MouseEvent, KeyboardEvent, FocusEvent } from 'react';
function FormulaireComplet() {
// --- Input / Select / Textarea ---
const handleInput = (e: ChangeEvent<HTMLInputElement>) => {
const valeur: string = e.target.value;
const checked: boolean = e.target.checked; // Pour les checkboxes
const fichiers = e.target.files; // Pour type="file"
};
const handleSelect = (e: ChangeEvent<HTMLSelectElement>) => {
const valeur: string = e.target.value;
// Pour les selects multiples
const valeurs: string[] = Array.from(e.target.selectedOptions, o => o.value);
};
const handleTextarea = (e: ChangeEvent<HTMLTextAreaElement>) => {
const texte: string = e.target.value;
};
// --- Formulaire ---
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault(); // Empêcher le rechargement de la page
const formData = new FormData(e.currentTarget);
const email = formData.get('email') as string;
};
// --- Clic ---
const handleClick = (e: MouseEvent<HTMLButtonElement>) => {
e.stopPropagation(); // Empêcher la propagation
const { clientX, clientY } = e; // Position du curseur
};
// --- Clavier ---
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') { /* soumettre */ }
if (e.key === 'Escape') { /* fermer */ }
if (e.ctrlKey && e.key === 's') { /* sauvegarder */ }
};
// --- Focus ---
const handleFocus = (e: FocusEvent<HTMLInputElement>) => {
e.target.select(); // Sélectionner tout le texte au focus
};
return (
<form onSubmit={handleSubmit}>
<input onChange={handleInput} onKeyDown={handleKeyDown} onFocus={handleFocus} />
<select onChange={handleSelect}></select>
<textarea onChange={handleTextarea}></textarea>
<button type="submit" onClick={handleClick}>Envoyer</button>
</form>
);
}
Tableau de référence des types d'événements
| Événement React | Type TypeScript | Élément typique |
|---|---|---|
onChange |
ChangeEvent<HTMLInputElement> |
input, select, textarea |
onSubmit |
FormEvent<HTMLFormElement> |
form |
onClick |
MouseEvent<HTMLButtonElement> |
button, div, a |
onKeyDown |
KeyboardEvent<HTMLInputElement> |
input, div (tabindex) |
onFocus / onBlur |
FocusEvent<HTMLInputElement> |
input, textarea |
onDragStart |
DragEvent<HTMLDivElement> |
div draggable |
onScroll |
UIEvent<HTMLDivElement> |
div scrollable |
Composants génériques avec TypeScript
Les composants génériques permettent de créer des composants réutilisables qui fonctionnent avec n'importe quel type tout en conservant la sécurité de type. C'est l'un des patterns les plus puissants de React + TypeScript.
Liste générique typée
// T est le type des éléments — déterminé lors de l'utilisation
interface ListeProps<T> {
items: T[];
getKey: (item: T) => string | number; // Fonction pour extraire la clé
renderItem: (item: T) => React.ReactNode; // Render prop générique
itemVide?: React.ReactNode; // Contenu quand la liste est vide
}
// La syntaxe <T,> (avec virgule) est nécessaire dans les fichiers .tsx
// pour que TypeScript ne confonde pas <T> avec du JSX
function Liste<T,>({ items, getKey, renderItem, itemVide }: ListeProps<T>) {
if (items.length === 0) {
return <div className="text-muted">{itemVide ?? 'Aucun élément'}</div>;
}
return (
<ul className="list-group">
{items.map(item => (
<li key={getKey(item)} className="list-group-item">
{renderItem(item)}
</li>
))}
</ul>
);
}
// Utilisation avec des Utilisateurs — T = Utilisateur
<Liste
items={utilisateurs}
getKey={u => u.id}
renderItem={u => <span>{u.nom} — {u.email}</span>}
itemVide={<p>Aucun utilisateur trouvé</p>}
/>
// Utilisation avec des Produits — T = Produit
<Liste
items={produits}
getKey={p => p.reference}
renderItem={p => <span>{p.nom} — {p.prix}€</span>}
/>
Sélecteur générique (Select dropdown)
// Composant Select réutilisable pour n'importe quel type d'option
interface SelectProps<T,> {
options: T[];
valeur: T | null;
onChangement: (nouvelleValeur: T) => void;
getLabel: (option: T) => string; // Texte affiché dans le select
getValeur: (option: T) => string; // Valeur soumise dans le formulaire
placeholder?: string;
disabled?: boolean;
}
function Select<T,>({
options,
valeur,
onChangement,
getLabel,
getValeur,
placeholder = 'Sélectionner...',
disabled = false,
}: SelectProps<T>) {
const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const valeurSelectionnee = e.target.value;
// Retrouver l'objet complet depuis la valeur de l'option
const option = options.find(o => getValeur(o) === valeurSelectionnee);
if (option) onChangement(option);
};
return (
<select
className="form-select"
value={valeur ? getValeur(valeur) : ''}
onChange={handleChange}
disabled={disabled}
>
<option value="">{placeholder}</option>
{options.map(option => (
<option key={getValeur(option)} value={getValeur(option)}>
{getLabel(option)}
</option>
))}
</select>
);
}
// Utilisation avec des pays
interface Pays { code: string; nom: string; }
const pays: Pays[] = [{ code: 'FR', nom: 'France' }, { code: 'ES', nom: 'Espagne' }];
<Select<Pays>
options={pays}
valeur={paysSelectionne}
onChangement={setPaysSelectionne}
getLabel={p => p.nom}
getValeur={p => p.code}
/>
Typer le Context API
Le Context API requiert une attention particulière avec TypeScript. Voici le pattern le plus robuste pour typer un contexte sans compromettre la sécurité des types.
import { createContext, useContext, useState, useMemo } from 'react';
// 1. Définir le type de la valeur du contexte
interface ThemeContextType {
theme: 'light' | 'dark';
couleurPrincipale: string;
toggleTheme: () => void;
setCouleur: (couleur: string) => void;
}
// 2. Créer le contexte avec null comme valeur initiale
// null signifie "pas encore dans un Provider"
const ThemeContext = createContext<ThemeContextType | null>(null);
// 3. Hook avec vérification de type
export function useTheme(): ThemeContextType {
const contexte = useContext(ThemeContext);
// Assertion de type : si null, l'usage est incorrect
if (contexte === null) {
throw new Error(
'useTheme() doit être appelé à l\'intérieur d\'un <ThemeProvider>.'
);
}
// TypeScript sait maintenant que contexte est ThemeContextType (pas null)
return contexte;
}
// 4. Provider typé
interface ThemeProviderProps {
children: React.ReactNode;
themeInitial?: 'light' | 'dark'; // Prop optionnelle avec valeur par défaut
couleurInitiale?: string;
}
export function ThemeProvider({
children,
themeInitial = 'light',
couleurInitiale = '#0d6efd',
}: ThemeProviderProps) {
const [theme, setTheme] = useState<'light' | 'dark'>(themeInitial);
const [couleur, setCouleur] = useState<string>(couleurInitiale);
const valeur = useMemo<ThemeContextType>(() => ({
theme,
couleurPrincipale: couleur,
toggleTheme: () => setTheme(t => t === 'light' ? 'dark' : 'light'),
setCouleur,
}), [theme, couleur]);
return (
<ThemeContext.Provider value={valeur}>
{children}
</ThemeContext.Provider>
);
}
Erreurs fréquentes et solutions
Erreur 1 — Utiliser any au lieu du bon type
// ❌ any désactive TypeScript — aucune sécurité
function Composant({ data }: { data: any }) {
return <p>{data.proprieteInexistante}</p>; // Pas d'erreur, bug en runtime
}
// ✅ Typer précisément ou utiliser unknown avec une assertion
interface DonneesAPI {
id: number;
titre: string;
}
function Composant({ data }: { data: DonneesAPI }) {
return <p>{data.titre}</p>; // TypeScript vérifie que titre existe
}
// Pour des données vraiment inconnues : unknown + type guard
function traiterDonnees(data: unknown): string {
if (typeof data === 'string') return data;
if (typeof data === 'object' && data !== null && 'titre' in data) {
return (data as DonneesAPI).titre;
}
return 'Données inconnues';
}
Erreur 2 — Confondre HTMLElement et les types React
// ❌ HTMLElement est trop générique pour un ref sur un input
const ref = useRef<HTMLElement>(null);
ref.current?.focus(); // OK mais pas d'autocomplétion des props d'input
// ✅ Utiliser le type exact de l'élément ciblé
const inputRef = useRef<HTMLInputElement>(null);
const divRef = useRef<HTMLDivElement>(null);
const formRef = useRef<HTMLFormElement>(null);
const videoRef = useRef<HTMLVideoElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
// Maintenant l'autocomplétion fonctionne
inputRef.current?.select(); // Méthode spécifique à HTMLInputElement
formRef.current?.reset(); // Méthode spécifique à HTMLFormElement
videoRef.current?.play(); // Méthode spécifique à HTMLVideoElement
Erreur 3 — Oublier les types pour les props de spread
// ❌ TypeScript ne sait pas ce que contient ...reste
function BoutonCustom({ label, ...reste }) {
return <button {...reste}>{label}</button>;
}
// ✅ Typer les props natives avec React.ButtonHTMLAttributes
interface BoutonCustomProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
label: string;
variante?: 'primary' | 'secondary';
}
function BoutonCustom({ label, variante = 'primary', className, ...reste }: BoutonCustomProps) {
const classes = `btn btn-${variante} ${className ?? ''}`.trim();
// ...reste contient toutes les props natives de <button> correctement typées
return <button className={classes} {...reste}>{label}</button>;
}
// Maintenant toutes les props HTML natives sont disponibles et typées
<BoutonCustom
label="Enregistrer"
variante="primary"
disabled={enChargement}
aria-label="Enregistrer le formulaire"
onClick={handleSubmit}
/>
Erreur 4 — Assertion de type abusive avec "as"
// ❌ Forcer un type avec "as" sans vérification — dangereux
const element = document.getElementById('mon-input') as HTMLInputElement;
element.value = 'test'; // Crash si l'élément n'existe pas ou n'est pas un input
// ✅ Vérifier avant d'asserter
const element = document.getElementById('mon-input');
if (element instanceof HTMLInputElement) {
// TypeScript sait maintenant que element est HTMLInputElement
element.value = 'test'; // Sûr
}
// Pour les données d'API : valider avec Zod plutôt qu'asserter
import { z } from 'zod';
const UtilisateurSchema = z.object({
id: z.number(),
nom: z.string(),
email: z.string().email(),
});
async function fetchUtilisateur(id: number) {
const response = await fetch(`/api/users/${id}`);
const data = await response.json();
// Valide ET type en même temps — erreur si les données ne correspondent pas
return UtilisateurSchema.parse(data);
}
Checklist TypeScript + React
- Toutes les props ont une interface ou un type explicite (
interface XxxProps) - Les props optionnelles utilisent
?avec une valeur par défaut dans la déstructuration -
childrenest typéReact.ReactNode(ouPropsWithChildren) - Les événements utilisent le bon type générique (
ChangeEvent<HTMLInputElement>…) -
useRefutilise le type exact de l'élément DOM (HTMLInputElement, nonHTMLElement) -
useStateinfère le type ou utilise le générique (useState<T | null>(null)) - Les actions de
useReducersont une union discriminée - Le Context est créé avec
nullet vérifié dans le Hook personnalisé - Aucun
anysans raison documentée — préférerunknown+ type guard - Les assertions
assont vérifiées par uninstanceofou une validation Zod - Les composants réutilisables passent par des generics
<T,>si nécessaire - Le fichier
tsconfig.jsonastrict: trueactivé