Cours JavaScript et React · Module 12: React et TypeScript

Typer le Context API et useReducer

7 min de lecture
Dans cette leçon5

Le thème de couleur doit atteindre chaque panneau depuis la passerelle, et la liste des missions chaque cabine. Passer des props à travers cinq niveaux de composants est épuisant, et sans types il est facile d'envoyer une action avec une faute de frappe que le reducer ignore en silence. Le Context API est un système de communication entre des modules éloignés du vaisseau spatial - il permet de transmettre des données sans les faire passer par chaque composant intermédiaire. TypeScript garantit que ces données ont toujours la bonne forme.

Typer le Context API

On construit un contexte typé en quatre étapes. D'abord une interface qui décrit ce que le contexte fournit, puis le contexte lui-même avec createContext. On crée le contexte avec la valeur undefined et le type ThemeContextType | undefined - je t'explique pourquoi dans un instant.

1import React, { createContext, useContext, useState } from 'react';
2
3// 1. Définir le type du contexte
4interface ThemeContextType {
5  theme: "dark" | "light";
6  toggleTheme: () => void;
7}
8
9// 2. Créer le contexte avec une valeur par défaut
10const ThemeContext = createContext<ThemeContextType | undefined>(undefined);

La troisième étape est un hook personnalisé qui lit le contexte et vérifie que le composant se trouve bien dans un Provider. La quatrième est le Provider, le composant qui détient l'état et le partage avec ses descendants :

1// 3. Hook personnalisé pour utiliser le contexte (avec validation)
2function useTheme(): ThemeContextType {
3  const context = useContext(ThemeContext);
4  if (context === undefined) {
5    throw new Error("useTheme must be used inside ThemeProvider");
6  }
7  return context;
8}
9
10// 4. Composant Provider
11function ThemeProvider({ children }: { children: React.ReactNode }) {
12  const [theme, setTheme] = useState<"dark" | "light">("dark");
13
14  const toggleTheme = () => {
15    setTheme(prev => prev === "dark" ? "light" : "dark");
16  };
17
18  return (
19    <ThemeContext.Provider value={{ theme, toggleTheme }}>
20      {children}
21    </ThemeContext.Provider>
22  );
23}

Après l'instruction if, TypeScript rétrécit context en ThemeContextType, donc le hook renvoie un type sans undefined. Nouveauté de React 19 : tu peux rendre le contexte lui-même comme Provider en écrivant <ThemeContext value={...}>. <ThemeContext.Provider> fonctionne toujours et tu le verras dans la plupart du code, mais l'équipe React a annoncé qu'il deviendra obsolète dans une future version.

Pourquoi utiliser undefined comme valeur par défaut ?

La valeur par défaut de createContext n'atteint que les composants qui n'ont aucun Provider au-dessus d'eux. Compare les deux approches :

1// Modèle : createContext<T | undefined>(undefined)
2// + hook personnalisé avec validation
3
4// AVANTAGES :
5// - Erreur à l'exécution si le composant n'est pas dans un Provider
6// - Le type dans le composant est toujours ThemeContextType (pas undefined)
7// - Plus sûr que de fournir un objet par défaut "vide"
8
9// ALTERNATIVE (moins sûre) :
10const ThemeContext = createContext<ThemeContextType>({
11  theme: "dark",
12  toggleTheme: () => {} // fonction vide - bug silencieux
13});

Dans l'alternative, un composant hors du Provider reçoit une fonction vide - le bouton de thème ne fait tout simplement rien et personne ne comprend pourquoi. Le modèle avec undefined transforme les pannes silencieuses en message bien visible. C'est ce modèle que je te recommande.

Typer useReducer

useReducer est idéal pour gérer un état complexe. Un reducer est une fonction pure qui reçoit l'état actuel et une action, et renvoie le nouvel état. TypeScript rend chaque action et chaque changement d'état sûrs. On commence par le type de l'état et le type d'une mission :

1// 1. Définir le type de l'état
2interface MissionState {
3  missions: Mission[];
4  selectedMission: Mission | null;
5  loading: boolean;
6  error: string | null;
7}
8
9interface Mission {
10  id: string;
11  name: string;
12  status: "planned" | "active" | "completed";
13}

On décrit les actions comme une union discriminée de la leçon précédente. Le discriminant est le champ type, et chaque variante a son propre type de payload. Mission["status"] reprend le type du champ status de l'interface, pour ne pas répéter la liste des statuts :

1// 2. Définir les types d'actions - une union discriminée !
2type MissionAction =
3  | { type: "SET_MISSIONS"; payload: Mission[] }
4  | { type: "SELECT_MISSION"; payload: string }
5  | { type: "ADD_MISSION"; payload: Mission }
6  | { type: "UPDATE_STATUS"; payload: { id: string; status: Mission["status"] } }
7  | { type: "SET_LOADING"; payload: boolean }
8  | { type: "SET_ERROR"; payload: string | null };

Passons au reducer. Dans chaque case, TypeScript connaît le type exact de action.payload : dans "ADD_MISSION" c'est Mission, dans "SET_LOADING" c'est boolean.

1// 3. Reducer avec typage complet
2function missionReducer(state: MissionState, action: MissionAction): MissionState {
3  switch (action.type) {
4    case "SET_MISSIONS":
5      return { ...state, missions: action.payload, loading: false };
6    case "SELECT_MISSION":
7      return {
8        ...state,
9        selectedMission: state.missions.find(m => m.id === action.payload) || null
10      };
11    case "ADD_MISSION":
12      return { ...state, missions: [...state.missions, action.payload] };
13    case "UPDATE_STATUS":
14      return {
15        ...state,
16        missions: state.missions.map(m =>
17          m.id === action.payload.id
18            ? { ...m, status: action.payload.status }
19            : m
20        )
21      };
22    case "SET_LOADING":
23      return { ...state, loading: action.payload };
24    case "SET_ERROR":
25      return { ...state, error: action.payload, loading: false };
26    default:
27      return state;
28  }
29}

Le reducer ne modifie jamais l'ancien état - il renvoie toujours un nouvel objet via ...state. L'action { type: "ADD_MISION" } avec une faute de frappe ne compile pas, car elle ne correspond à aucune variante.

Combiner Context + useReducer

On connecte maintenant le reducer à un contexte, pour que chaque composant de la flotte puisse lire l'état et envoyer des actions. Le type React.Dispatch<MissionAction> décrit une fonction dispatch qui n'accepte que des actions valides :

1// État initial
2const initialState: MissionState = {
3  missions: [],
4  selectedMission: null,
5  loading: false,
6  error: null
7};
8
9// Type du contexte
10interface MissionContextType {
11  state: MissionState;
12  dispatch: React.Dispatch<MissionAction>;
13}
14
15const MissionContext = createContext<MissionContextType | undefined>(undefined);

Le hook et le Provider reprennent le modèle du thème. Remarque que useReducer ne reçoit aucun générique - il déduit les types de missionReducer et initialState. La signature de types de ce hook a changé dans React 19 et l'ancienne forme useReducer<State, Action>(...) signale désormais une erreur, alors fie-toi à l'inférence.

1// Hook personnalisé
2function useMissions(): MissionContextType {
3  const context = useContext(MissionContext);
4  if (!context) {
5    throw new Error("useMissions must be used inside MissionProvider");
6  }
7  return context;
8}
9
10// Provider
11function MissionProvider({ children }: { children: React.ReactNode }) {
12  const [state, dispatch] = useReducer(missionReducer, initialState);
13
14  return (
15    <MissionContext.Provider value={{ state, dispatch }}>
16      {children}
17    </MissionContext.Provider>
18  );
19}

Utilisation dans les composants

Le composant de liste des missions récupère l'état et dispatch en un seul appel de hook. L'éditeur propose les valeurs de type autorisées, et après avoir choisi "SELECT_MISSION" il exige un payload de type string :

1function MissionList() {
2  const { state, dispatch } = useMissions();
3
4  if (state.loading) return <p>Loading missions...</p>;
5  if (state.error) return <p>Error: {state.error}</p>;
6
7  return (
8    <div>
9      {state.missions.map(mission => (
10        <div key={mission.id} onClick={() =>
11          dispatch({ type: "SELECT_MISSION", payload: mission.id })
12        }>
13          <h3>{mission.name}</h3>
14          <span>{mission.status}</span>
15        </div>
16      ))}
17    </div>
18  );
19}

MissionList ne sait rien de l'endroit où vit l'état - il ne connaît que le hook. Dans le projet final, la même organisation gérera le thème de tout le tableau de bord.

Code de cette leçon : App.tsx
1import React, { createContext, useContext, useReducer, useState } from 'react';
2
3// ===== TYPES =====
4interface CrewMember {
5  id: string;
6  name: string;
7  role: string;
8  status: "active" | "resting" | "mission";
9}
10
11// État de l'application
12interface AppState {
13  crew: CrewMember[];
14  theme: "dark" | "light";
15  notifications: string[];
16}
17
18// Union discriminée des actions
19type AppAction =
20  | { type: "ADD_CREW"; payload: CrewMember }
21  | { type: "REMOVE_CREW"; payload: string }
22  | { type: "UPDATE_STATUS"; payload: { id: string; status: CrewMember["status"] } }
23  | { type: "TOGGLE_THEME" }
24  | { type: "ADD_NOTIFICATION"; payload: string }
25  | { type: "CLEAR_NOTIFICATIONS" };
26
27// ===== REDUCER =====
28function appReducer(state: AppState, action: AppAction): AppState {
29  switch (action.type) {
30    case "ADD_CREW":
31      return {
32        ...state,
33        crew: [...state.crew, action.payload],
34        notifications: [...state.notifications, "Ajouté : " + action.payload.name]
35      };
36    case "REMOVE_CREW":
37      return {
38        ...state,
39        crew: state.crew.filter(c => c.id !== action.payload),
40        notifications: [...state.notifications, "Membre d'équipage supprimé"]
41      };
42    case "UPDATE_STATUS":
43      return {
44        ...state,
45        crew: state.crew.map(c =>
46          c.id === action.payload.id ? { ...c, status: action.payload.status } : c
47        )
48      };
49    case "TOGGLE_THEME":
50      return { ...state, theme: state.theme === "dark" ? "light" : "dark" };
51    case "ADD_NOTIFICATION":
52      return { ...state, notifications: [...state.notifications, action.payload] };
53    case "CLEAR_NOTIFICATIONS":
54      return { ...state, notifications: [] };
55    default:
56      return state;
57  }
58}
59
60// ===== CONTEXT =====
61interface AppContextType {
62  state: AppState;
63  dispatch: React.Dispatch<AppAction>;
64}
65
66const AppContext = createContext<AppContextType | undefined>(undefined);
67
68function useApp(): AppContextType {
69  const context = useContext(AppContext);
70  if (!context) throw new Error("useApp nécessite AppProvider");
71  return context;
72}
73
74// ===== PROVIDER =====
75const initialState: AppState = {
76  crew: [
77    { id: "1", name: "Nova", role: "Capitaine", status: "active" },
78    { id: "2", name: "Ra", role: "Navigateur", status: "active" },
79    { id: "3", name: "Astro", role: "Ingénieur", status: "resting" },
80  ],
81  theme: "dark",
82  notifications: []
83};
84
85function AppProvider({ children }: { children: React.ReactNode }) {
86  const [state, dispatch] = useReducer(appReducer, initialState);
87  return (
88    <AppContext.Provider value={{ state, dispatch }}>
89      <div className={"app " + state.theme}>{children}</div>
90    </AppContext.Provider>
91  );
92}
93
94// ===== COMPOSANTS =====
95function Header() {
96  const { state, dispatch } = useApp();
97  return (
98    <header className="header">
99      <h1>Centre de commandement</h1>
100      <button onClick={() => dispatch({ type: "TOGGLE_THEME" })}>
101        Thème {state.theme === "dark" ? "clair" : "sombre"}
102      </button>
103    </header>
104  );
105}
106
107function CrewList() {
108  const { state, dispatch } = useApp();
109  const statusColors = { active: "#22c55e", resting: "#f59e0b", mission: "#818cf8" };
110  const statuses: CrewMember["status"][] = ["active", "resting", "mission"];
111
112  return (
113    <div className="section">
114      <h2>Équipage ({state.crew.length})</h2>
115      {state.crew.map(member => (
116        <div key={member.id} className="crew-card">
117          <div>
118            <strong>{member.name}</strong> - {member.role}
119          </div>
120          <div className="crew-actions">
121            <select
122              value={member.status}
123              onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
124                dispatch({
125                  type: "UPDATE_STATUS",
126                  payload: { id: member.id, status: e.target.value as CrewMember["status"] }
127                })
128              }
129            >
130              {statuses.map(s => <option key={s} value={s}>{s}</option>)}
131            </select>
132            <span style={{ color: statusColors[member.status] }}>{member.status}</span>
133            <button className="small-btn danger"
134              onClick={() => dispatch({ type: "REMOVE_CREW", payload: member.id })}>
135              X
136            </button>
137          </div>
138        </div>
139      ))}
140    </div>
141  );
142}
143
144function AddCrewForm() {
145  const { dispatch } = useApp();
146  const [name, setName] = useState("");
147  const [role, setRole] = useState("");
148
149  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
150    e.preventDefault();
151    if (!name || !role) return;
152    dispatch({
153      type: "ADD_CREW",
154      payload: { id: Date.now().toString(), name, role, status: "active" }
155    });
156    setName("");
157    setRole("");
158  };
159
160  return (
161    <form onSubmit={handleSubmit} className="add-form">
162      <input value={name} onChange={e => setName(e.target.value)} placeholder="Prénom" />
163      <input value={role} onChange={e => setRole(e.target.value)} placeholder="Rôle" />
164      <button type="submit">Ajouter</button>
165    </form>
166  );
167}
168
169function Notifications() {
170  const { state, dispatch } = useApp();
171  if (state.notifications.length === 0) return null;
172  return (
173    <div className="section notifications">
174      <h2>Notifications ({state.notifications.length})</h2>
175      {state.notifications.map((n, i) => <p key={i} className="notif">{n}</p>)}
176      <button onClick={() => dispatch({ type: "CLEAR_NOTIFICATIONS" })}>Effacer</button>
177    </div>
178  );
179}
180
181function App() {
182  return (
183    <AppProvider>
184      <Header />
185      <AddCrewForm />
186      <CrewList />
187      <Notifications />
188    </AppProvider>
189  );
190}
191
192export default App;

Retiens : le contexte est le réseau de communication de la flotte et le type d'action son chiffre - un message mal chiffré ne quitte jamais la passerelle.

Tu as repéré une erreur dans cette leçon ?

Teste tes connaissances

Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.

  1. 1. Pourquoi est-il recommandé d'utiliser createContext<Type | undefined>(undefined) au lieu de fournir un objet par défaut ?

  2. 2. Pourquoi est-il préférable de définir les types d'actions dans useReducer comme une union discriminée ?

Voici 2 des 3 questions de cette leçon. Réponds aux autres dans le jeu.

Tâches pratiques dans le jeu

  • Tri vertical

    Ordonne les étapes de création d'un Context typé avec useReducer dans le bon ordre :

  • Éditeur de code

    Typage complet du Context API avec useReducer

  • Clique dans l'ordre

    Ordonne le code d'un hook personnalisé avec validation du contexte :

  • Tri horizontal

    Ordonne le type correct pour dispatch dans l'interface du contexte :

  • Tri vertical

    Ordonne les concepts TypeScript en React du plus simple au plus complexe :

  • Tri horizontal

    Ordonne la syntaxe pour créer un contexte React typé :

  • Clique dans l'ordre

    Ordonne la signature d'une fonction reducer typée :

  • Tri vertical

    Ordonne le flux de données dans l'architecture Context + useReducer dans le bon ordre :

  • Tri horizontal

    Ordonne un appel de dispatch correct avec une action typée :

  • Clique dans l'ordre

    Ordonne les éléments d'un composant Provider typé :

  • Tri vertical

    Ordonne les avantages de TypeScript en React du plus basique au plus avancé :

  • Éditeur de code

    Exercice complet combinant tous les concepts TypeScript en React

Articles utiles