Cours JavaScript et React · Module 3: État et événements

L'état d'un composant (State)

8 min de lecture
Dans cette leçon6

Lors d'un voyage cosmique à travers la galaxie React, l'état est comme le carburant de la fusée qui alimente l'interactivité de tes composants. Sans état, les composants seraient des objets statiques - comme des planètes sans atmosphère. L'état permet aux composants de « se souvenir » d'informations et de réagir aux changements, rendant tes applications vivantes et réactives.

Qu'est-ce que l'état d'un composant ?

L'état est une donnée (un nombre, un texte, un booléen, un tableau ou un objet) dont le composant se souvient entre deux rendus. Contrairement aux props, que le parent transmet au composant, l'état appartient au composant lui-même et lui seul peut le modifier - toujours via sa fonction de mise à jour, par ex. setFuel, jamais en le modifiant directement.

Imagine le tableau de bord d'un vaisseau spatial. Les props sont comme des coordonnées de destination fixées - tu ne peux pas les changer depuis le tableau de bord. L'état, en revanche, est comme les relevés actuels de vitesse, d'altitude et de température - des données qui changent constamment et qui sont contrôlées par le tableau de bord lui-même.

Gérer l'état dans les composants fonctionnels

Dans le React moderne, l'état des composants fonctionnels est géré à l'aide du hook useState. Ce hook simple mais puissant te permet d'ajouter un état aux composants fonctionnels.

Syntaxe de base de useState

1import React, { useState } from 'react';
2
3function ControlPanel() {
4  // Déclaration d'une variable d'état "fuel" avec une valeur initiale de 100
5  const [fuel, setFuel] = useState(100);
6
7  // fuel - la valeur actuelle de l'état
8  // setFuel - la fonction pour mettre à jour l'état
9
10  return (
11    <div className="control-panel">
12      <h2>Panneau de commande</h2>
13      <p>Niveau de carburant : {fuel}%</p>
14      <button onClick={() => setFuel(fuel - 10)}>
15        Consommer du carburant
16      </button>
17    </div>
18  );
19}

Dans l'exemple ci-dessus :

  1. On importe le hook useState depuis React
  2. On appelle useState(100) pour créer une variable d'état fuel avec une valeur initiale de 100
  3. On déstructure le résultat pour obtenir :
    • fuel : la valeur actuelle de l'état
    • setFuel : la fonction pour mettre à jour cette valeur
  4. On utilise setFuel dans le gestionnaire de l'événement de clic pour diminuer la valeur de fuel de 10

Utiliser plusieurs variables d'état

Un composant peut avoir plusieurs variables d'état indépendantes :

1function SpaceshipDashboard() {
2  const [fuel, setFuel] = useState(100);
3  const [speed, setSpeed] = useState(0);
4  const [shields, setShields] = useState(true);
5  const [destination, setDestination] = useState('Orbite');
6
7  return (
8    <div className="dashboard">
9      <h2>Tableau de bord du vaisseau</h2>
10      <div className="gauges">
11        <div className="gauge">
12          <h3>Carburant</h3>
13          <p>{fuel}%</p>
14          <button onClick={() => setFuel(Math.max(0, fuel - 5))}>
15            Augmenter la poussée
16          </button>
17        </div>
18
19        <div className="gauge">
20          <h3>Vitesse</h3>
21          <p>{speed} km/s</p>
22          <button onClick={() => setSpeed(speed + 1)}>
23            Accélérer
24          </button>
25          <button onClick={() => setSpeed(Math.max(0, speed - 1))}>
26            Ralentir
27          </button>
28        </div>
29
30        <div className="gauge">
31          <h3>Boucliers</h3>
32          <p>{shields ? 'Activés' : 'Désactivés'}</p>
33          <button onClick={() => setShields(!shields)}>
34            Basculer
35          </button>
36        </div>
37
38        <div className="gauge">
39          <h3>Destination</h3>
40          <p>{destination}</p>
41          <select
42            value={destination}
43            onChange={(e) => setDestination(e.target.value)}
44          >
45            <option value="Orbite">Orbite terrestre</option>
46            <option value="Lune">Lune</option>
47            <option value="Mars">Mars</option>
48            <option value="Ceinture d'astéroïdes">Ceinture d'astéroïdes</option>
49          </select>
50        </div>
51      </div>
52    </div>
53  );
54}

Structures d'état complexes

L'état peut être une valeur simple (nombre, chaîne, booléen), mais il peut aussi être une structure plus complexe, comme un tableau ou un objet :

1function CrewManagement() {
2  const [crew, setCrew] = useState([
3    { id: 1, name: 'Anna Nowak', role: 'Capitaine', onDuty: true },
4    { id: 2, name: 'Jan Kowalski', role: 'Navigateur', onDuty: false },
5    { id: 3, name: 'Maria Wisniewska', role: 'Ingénieure', onDuty: true }
6  ]);
7
8  const toggleDuty = (crewId) => {
9    setCrew(crew.map(member =>
10      member.id === crewId
11        ? { ...member, onDuty: !member.onDuty }
12        : member
13    ));
14  };
15
16  return (
17    <div className="crew-management">
18      <h2>Gestion de l'équipage</h2>
19      <ul className="crew-list">
20        {crew.map(member => (
21          <li key={member.id} className={member.onDuty ? 'on-duty' : 'off-duty'}>
22            <strong>{member.name}</strong> - {member.role}
23            <button onClick={() => toggleDuty(member.id)}>
24              {member.onDuty ? 'Terminer le service' : 'Commencer le service'}
25            </button>
26          </li>
27        ))}
28      </ul>
29    </div>
30  );
31}

Dans cet exemple, l'état crew est un tableau d'objets. La fonction toggleDuty utilise map pour créer un nouveau tableau avec l'état mis à jour d'un membre d'équipage donné.

L'état comme un instantané

En React, l'état fonctionne comme un instantané (snapshot). Quand React effectue le rendu d'un composant, il « capture » l'état à cet instant, et toutes les valeurs à l'intérieur de la fonction de rendu (y compris les gestionnaires d'événements) « verront » le même instantané de l'état.

Considère cet exemple :

1function RocketLauncher() {
2  const [countdown, setCountdown] = useState(10);
3
4  const decrementCountdown = () => {
5    // Cela NE fonctionnera PAS comme prévu si l'on veut
6    // effectuer plusieurs décrémentations dans un même cycle de rendu
7    setCountdown(countdown - 1); // Utilise la même valeur de l'instantané
8    setCountdown(countdown - 1); // Utilise encore la même valeur !
9  };
10
11  return (
12    <div className="rocket-launcher">
13      <h2>Compte à rebours avant le lancement : {countdown}</h2>
14      <button onClick={decrementCountdown}>
15        Réduire le compte à rebours
16      </button>
17    </div>
18  );
19}

Dans l'exemple ci-dessus, malgré le double appel à setCountdown, la valeur ne diminuera que de 1, et non de 2. Cela se produit parce que les deux mises à jour « voient » la même valeur de countdown issue de l'instantané.

Mettre à jour l'état à partir de l'état précédent

Pour résoudre le problème de l'instantané, React te permet de passer une fonction à la fonction de mise à jour de l'état. Cette fonction reçoit l'état précédent en argument :

1function RocketLauncher() {
2  const [countdown, setCountdown] = useState(10);
3
4  const decrementCountdown = () => {
5    // Cela fonctionnera correctement, même pour plusieurs mises à jour
6    setCountdown(prevCount => prevCount - 1); // À partir de la valeur précédente
7    setCountdown(prevCount => prevCount - 1); // À partir du résultat de la mise à jour précédente
8  };
9
10  return (
11    <div className="rocket-launcher">
12      <h2>Compte à rebours avant le lancement : {countdown}</h2>
13      <button onClick={decrementCountdown}>
14        Réduire le compte à rebours
15      </button>
16    </div>
17  );
18}

Maintenant, à chaque clic sur le bouton, le compte à rebours diminuera de 2.

Mettre à jour des objets dans l'état

Lorsqu'on met à jour des objets dans l'état, il est important de toujours créer un nouvel objet plutôt que de modifier l'objet existant. Cela respecte les principes d'immutabilité de React :

1function SpaceshipSystems() {
2  const [systems, setSystems] = useState({
3    engines: 'offline',
4    lifeSupportOutput: 100,
5    navigationStatus: 'calibrating',
6    shields: 85
7  });
8
9  const startEngines = () => {
10    // INCORRECT - modifier l'objet original
11    // systems.engines = 'online';
12    // setSystems(systems);
13
14    // CORRECT - créer un nouvel objet
15    setSystems({
16      ...systems, // copie toutes les propriétés existantes
17      engines: 'online' // ne remplace que celle que l'on veut changer
18    });
19  };
20
21  return (
22    <div className="spaceship-systems">
23      <h2>État des systèmes</h2>
24      <p>Moteurs : {systems.engines}</p>
25      <p>Système de survie : {systems.lifeSupportOutput}%</p>
26      <p>Navigation : {systems.navigationStatus}</p>
27      <p>Boucliers : {systems.shields}%</p>
28
29      <button onClick={startEngines}>
30        Démarrer les moteurs
31      </button>
32    </div>
33  );
34}

Mettre à jour des tableaux dans l'état

Comme pour les objets, lorsqu'on met à jour des tableaux il faut aussi respecter les principes d'immutabilité :

1function MissionLog() {
2  const [logs, setLogs] = useState([
3    { id: 1, time: '08:00', message: 'Début de la mission' }
4  ]);
5
6  const addLog = () => {
7    const newLog = {
8      id: Math.max(0, ...logs.map(log => log.id)) + 1, // plus grand id + 1 : il ne se répète pas après une suppression
9      time: new Date().toLocaleTimeString(),
10      message: 'Nouvelle entrée du journal'
11    };
12
13    // INCORRECT - muter le tableau original
14    // logs.push(newLog);
15    // setLogs(logs);
16
17    // CORRECT - créer un nouveau tableau
18    setLogs([...logs, newLog]); // ajouter à la fin
19    // ou
20    // setLogs([newLog, ...logs]); // ajouter au début
21  };
22
23  const removeLog = (logId) => {
24    // Filtrage - créer un nouveau tableau sans l'élément supprimé
25    setLogs(logs.filter(log => log.id !== logId));
26  };
27
28  return (
29    <div className="mission-log">
30      <h2>Journal de mission</h2>
31      <button onClick={addLog}>Ajouter une entrée</button>
32
33      <ul className="log-entries">
34        {logs.map(log => (
35          <li key={log.id}>
36            <span className="time">{log.time}</span>
37            <span className="message">{log.message}</span>
38            <button onClick={() => removeLog(log.id)}>Supprimer</button>
39          </li>
40        ))}
41      </ul>
42    </div>
43  );
44}

Tu retrouves ces mêmes opérations sur un tableau - ajouter, basculer et supprimer une entrée - dans la démo ci-dessous, avec un compteur (un nombre), un interrupteur (un booléen) et un champ de texte (du texte). Regarde l'encadré en bas : on appelle useState uniquement au niveau supérieur d'un composant, jamais dans une boucle, une condition ou une fonction imbriquée, car React distingue les états grâce à l'ordre des appels à chaque rendu.

Code de cette leçon : App.jsx
1import React, { useState } from 'react';
2
3// Compteur simple - les bases de useState
4function SimpleCounter() {
5  const [count, setCount] = useState(0);
6
7  return (
8    <div style={{
9      backgroundColor: '#252545',
10      padding: '20px',
11      borderRadius: '12px',
12      textAlign: 'center'
13    }}>
14      <h3 style={{ color: '#00d4ff', marginTop: 0 }}>Compteur simple</h3>
15      <div style={{
16        fontSize: '48px',
17        fontWeight: 'bold',
18        margin: '20px 0',
19        color: count > 0 ? '#4caf50' : count < 0 ? '#f44336' : 'white'
20      }}>
21        {count}
22      </div>
23      <div style={{ display: 'flex', gap: '10px', justifyContent: 'center' }}>
24        <button
25          onClick={() => setCount(count - 1)}
26          style={{
27            padding: '10px 20px',
28            backgroundColor: '#f44336',
29            color: 'white',
30            border: 'none',
31            borderRadius: '8px',
32            cursor: 'pointer',
33            fontSize: '18px'
34          }}
35        >
36          -1
37        </button>
38        <button
39          onClick={() => setCount(0)}
40          style={{
41            padding: '10px 20px',
42            backgroundColor: '#666',
43            color: 'white',
44            border: 'none',
45            borderRadius: '8px',
46            cursor: 'pointer'
47          }}
48        >
49          Reset
50        </button>
51        <button
52          onClick={() => setCount(count + 1)}
53          style={{
54            padding: '10px 20px',
55            backgroundColor: '#4caf50',
56            color: 'white',
57            border: 'none',
58            borderRadius: '8px',
59            cursor: 'pointer',
60            fontSize: '18px'
61          }}
62        >
63          +1
64        </button>
65      </div>
66    </div>
67  );
68}
69
70// Interrupteur - état booléen
71function ToggleSwitch() {
72  const [isOn, setIsOn] = useState(false);
73
74  return (
75    <div style={{
76      backgroundColor: '#252545',
77      padding: '20px',
78      borderRadius: '12px',
79      textAlign: 'center'
80    }}>
81      <h3 style={{ color: '#00d4ff', marginTop: 0 }}>Interrupteur</h3>
82      <div
83        onClick={() => setIsOn(!isOn)}
84        style={{
85          width: '80px',
86          height: '40px',
87          backgroundColor: isOn ? '#4caf50' : '#666',
88          borderRadius: '20px',
89          cursor: 'pointer',
90          position: 'relative',
91          margin: '20px auto',
92          transition: 'background-color 0.3s'
93        }}
94      >
95        <div style={{
96          width: '32px',
97          height: '32px',
98          backgroundColor: 'white',
99          borderRadius: '50%',
100          position: 'absolute',
101          top: '4px',
102          left: isOn ? '44px' : '4px',
103          transition: 'left 0.3s'
104        }} />
105      </div>
106      <p style={{ color: '#ccc' }}>
107        Statut : <strong style={{ color: isOn ? '#4caf50' : '#f44336' }}>
108          {isOn ? 'ACTIVÉ' : 'DÉSACTIVÉ'}
109        </strong>
110      </p>
111    </div>
112  );
113}
114
115// Formulaire - état de type chaîne
116function TextInput() {
117  const [text, setText] = useState('');
118
119  return (
120    <div style={{
121      backgroundColor: '#252545',
122      padding: '20px',
123      borderRadius: '12px'
124    }}>
125      <h3 style={{ color: '#00d4ff', marginTop: 0 }}>Champ de texte</h3>
126      <input
127        type="text"
128        value={text}
129        onChange={(e) => setText(e.target.value)}
130        placeholder="Écris quelque chose..."
131        style={{
132          width: '100%',
133          padding: '12px 15px',
134          border: '2px solid #444',
135          borderRadius: '8px',
136          backgroundColor: '#1a1a2e',
137          color: 'white',
138          fontSize: '16px',
139          boxSizing: 'border-box'
140        }}
141      />
142      <p style={{ color: '#888', marginBottom: 0 }}>
143        Saisi : <strong style={{ color: 'white' }}>{text || '(vide)'}</strong>
144        {' '}({text.length} caractères)
145      </p>
146    </div>
147  );
148}
149
150// Liste - état de type tableau
151function TodoList() {
152  const [todos, setTodos] = useState([
153    { id: 1, text: 'Apprendre React', done: true },
154    { id: 2, text: 'Comprendre useState', done: false },
155    { id: 3, text: 'Créer un projet', done: false }
156  ]);
157  const [newTodo, setNewTodo] = useState('');
158
159  const addTodo = () => {
160    if (newTodo.trim()) {
161      setTodos([...todos, {
162        id: Date.now(),
163        text: newTodo,
164        done: false
165      }]);
166      setNewTodo('');
167    }
168  };
169
170  const toggleTodo = (id) => {
171    setTodos(todos.map(todo =>
172      todo.id === id ? { ...todo, done: !todo.done } : todo
173    ));
174  };
175
176  const deleteTodo = (id) => {
177    setTodos(todos.filter(todo => todo.id !== id));
178  };
179
180  return (
181    <div style={{
182      backgroundColor: '#252545',
183      padding: '20px',
184      borderRadius: '12px'
185    }}>
186      <h3 style={{ color: '#00d4ff', marginTop: 0 }}>Liste de tâches</h3>
187
188      <div style={{ display: 'flex', gap: '10px', marginBottom: '15px' }}>
189        <input
190          type="text"
191          value={newTodo}
192          onChange={(e) => setNewTodo(e.target.value)}
193          onKeyDown={(e) => e.key === 'Enter' && addTodo()}
194          placeholder="Nouvelle tâche..."
195          style={{
196            flex: 1,
197            padding: '10px 15px',
198            border: '2px solid #444',
199            borderRadius: '8px',
200            backgroundColor: '#1a1a2e',
201            color: 'white'
202          }}
203        />
204        <button
205          onClick={addTodo}
206          style={{
207            padding: '10px 20px',
208            backgroundColor: '#4caf50',
209            color: 'white',
210            border: 'none',
211            borderRadius: '8px',
212            cursor: 'pointer'
213          }}
214        >
215          Ajouter
216        </button>
217      </div>
218
219      <div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
220        {todos.map(todo => (
221          <div key={todo.id} style={{
222            display: 'flex',
223            alignItems: 'center',
224            gap: '10px',
225            padding: '10px 15px',
226            backgroundColor: '#1a1a2e',
227            borderRadius: '8px'
228          }}>
229            <input
230              type="checkbox"
231              checked={todo.done}
232              onChange={() => toggleTodo(todo.id)}
233              style={{ width: '20px', height: '20px', cursor: 'pointer' }}
234            />
235            <span style={{
236              flex: 1,
237              textDecoration: todo.done ? 'line-through' : 'none',
238              color: todo.done ? '#666' : 'white'
239            }}>
240              {todo.text}
241            </span>
242            <button
243              onClick={() => deleteTodo(todo.id)}
244              style={{
245                padding: '5px 10px',
246                backgroundColor: '#f44336',
247                color: 'white',
248                border: 'none',
249                borderRadius: '4px',
250                cursor: 'pointer'
251              }}
252            >
253              ×
254            </button>
255          </div>
256        ))}
257      </div>
258      <p style={{ color: '#888', marginBottom: 0, marginTop: '10px' }}>
259        Terminées : {todos.filter(t => t.done).length} / {todos.length}
260      </p>
261    </div>
262  );
263}
264
265export default function App() {
266  return (
267    <div style={{
268      fontFamily: 'Arial, sans-serif',
269      backgroundColor: '#0a0a1a',
270      minHeight: '100vh',
271      padding: '20px',
272      color: 'white'
273    }}>
274      <header style={{
275        textAlign: 'center',
276        marginBottom: '30px',
277        padding: '30px',
278        background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
279        borderRadius: '16px'
280      }}>
281        <h1 style={{ margin: 0 }}>useState dans React</h1>
282        <p style={{ margin: '10px 0 0 0', opacity: 0.9 }}>
283          Gestion de l'état des composants
284        </p>
285      </header>
286
287      <div style={{
288        display: 'grid',
289        gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
290        gap: '20px'
291      }}>
292        <SimpleCounter />
293        <ToggleSwitch />
294        <TextInput />
295        <TodoList />
296      </div>
297
298      <div style={{
299        backgroundColor: '#1a1a2e',
300        padding: '20px',
301        borderRadius: '12px',
302        marginTop: '30px'
303      }}>
304        <h3 style={{ color: '#ff9800', marginTop: 0 }}>Règles de useState :</h3>
305        <ul style={{ color: '#ccc', lineHeight: '1.8', paddingLeft: '20px' }}>
306          <li>À appeler uniquement au <strong>niveau supérieur</strong> du composant</li>
307          <li>Ne pas utiliser dans des boucles, des conditions ou des fonctions imbriquées</li>
308          <li>La mise à jour de l'état provoque un <strong>nouveau rendu</strong></li>
309          <li>L'état est <strong>isolé</strong> pour chaque instance du composant</li>
310        </ul>
311      </div>
312    </div>
313  );
314}

Chacun des quatre panneaux a son propre état indépendant : cliquer sur le compteur ne modifie pas la liste de tâches, car chaque composant garde son état séparément.

Quand utiliser l'état ?

L'état doit être utilisé pour des données qui :

  1. Changent au fil du temps
  2. Influencent le rendu du composant
  3. Ne peuvent pas être calculées à partir des props ou d'un autre état

Toutes les données ne doivent pas être dans l'état. Voici de bonnes questions à te poser :

  • La donnée provient-elle d'un parent via les props ? Si oui, elle ne devrait probablement pas être dans l'état.
  • La donnée reste-t-elle inchangée au fil du temps ? Si oui, elle ne devrait probablement pas être dans l'état.
  • La donnée peut-elle être calculée à partir de props ou d'un état existants ? Si oui, elle ne devrait surtout pas être dans l'état.

Résumé

L'état est un élément clé des composants React qui te permet de :

  • Stocker des données propres au composant
  • Gérer des données qui changent au fil du temps
  • Créer des interfaces utilisateur interactives qui répondent aux actions de l'utilisateur

Dans les sections suivantes, nous verrons comment gérer les événements en React et comment les combiner avec la gestion de l'état pour créer des applications entièrement interactives.

Code de cette leçon : App.jsx
1import React, { useState } from 'react';
2
3function ControlPanel() {
4  const [fuel, setFuel] = useState(100);
5  const [speed, setSpeed] = useState(0);
6  const [shields, setShields] = useState(true);
7
8  return (
9    <div className="control-panel">
10      <h1>Tableau de bord du vaisseau spatial</h1>
11
12      <div className="gauges">
13        <div className="gauge">
14          <h3>Carburant</h3>
15          <div className="progress-bar">
16            <div
17              className="progress-fill fuel"
18              style={{ width: `${fuel}%` }}
19            >
20              {fuel}%
21            </div>
22          </div>
23          <button
24            onClick={() => setFuel(Math.max(0, fuel - 10))}
25            disabled={fuel <= 0}
26          >
27            Augmenter la poussée (-10% carburant)
28          </button>
29        </div>
30
31        <div className="gauge">
32          <h3>Vitesse</h3>
33          <p className="speed-display">{speed} km/s</p>
34          <div className="speed-controls">
35            <button onClick={() => setSpeed(speed + 1)}>
36              Accélérer (+1)
37            </button>
38            <button
39              onClick={() => setSpeed(Math.max(0, speed - 1))}
40              disabled={speed <= 0}
41            >
42              Ralentir (-1)
43            </button>
44          </div>
45        </div>
46
47        <div className="gauge">
48          <h3>Boucliers</h3>
49          <p className={`shield-status ${shields ? 'active' : 'inactive'}`}>
50            {shields ? 'Activés' : 'Désactivés'}
51          </p>
52          <button onClick={() => setShields(!shields)}>
53            {shields ? 'Désactiver les boucliers' : 'Activer les boucliers'}
54          </button>
55        </div>
56      </div>
57    </div>
58  );
59}
60
61function App() {
62  return (
63    <div className="app">
64      <ControlPanel />
65    </div>
66  );
67}
68
69export default App;

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

Tâches pratiques dans le jeu

  • Tri horizontal

    Remets dans l'ordre la syntaxe de déclaration d'un état avec useState :

Articles utiles