Cours JavaScript et React · Module 9: Hooks React modernes
useOptimistic - mises à jour optimistes de l'UI
Dans cette leçon4
Un message vers la base martienne met plusieurs minutes à arriver, mais l'équipage ne veut pas fixer un indicateur qui tourne. Il veut voir son message dans la liste tout de suite, avec la mention « envoi ». Si la transmission échoue, le message doit disparaître. C'est exactement une mise à jour optimiste : on suppose le succès, et en cas d'échec on revient à l'état réel.
Le hook useOptimistic te permet de mettre à jour instantanément l'interface utilisateur avant de recevoir une réponse du serveur. C'est un outil clé pour créer des applications réactives qui donnent à l'utilisateur l'impression d'une réaction instantanée.
Les bases de useOptimistic
1. Syntaxe et utilisation de base
Le hook reçoit l'état réel et une fonction de mise à jour, et renvoie l'état optimiste ainsi qu'une fonction qui le modifie. La règle la plus importante de react.dev : cette fonction doit être appelée à l'intérieur d'une Action, c'est-à-dire dans startTransition ou dans une action de formulaire. En dehors d'une Action, React affiche un avertissement et le changement clignote puis disparaît aussitôt. D'abord, la logique de la liste de tâches :
1import { useOptimistic, useState, startTransition } from 'react';
2
3function TodoList() {
4 const [todos, setTodos] = useState([
5 { id: 1, text: 'Apprendre React', completed: false },
6 { id: 2, text: 'Créer une application', completed: false }
7 ]);
8
9 const [optimisticTodos, addOptimisticTodo] = useOptimistic(
10 todos,
11 // Fonction mettant à jour l'état optimiste
12 (currentTodos, newTodo) => [...currentTodos, newTodo]
13 );
14
15 async function handleAddTodo(text) {
16 const newTodo = {
17 id: Date.now(),
18 text,
19 completed: false,
20 pending: true // Marque comme en attente
21 };
22
23 // Mise à jour optimiste
24 addOptimisticTodo(newTodo);
25
26 try {
27 // Appel API réel
28 const response = await fetch('/api/todos', {
29 method: 'POST',
30 body: JSON.stringify({ text })
31 });
32
33 const savedTodo = await response.json();
34
35 // Met à jour l'état réel
36 startTransition(() => {
37 setTodos(current => [...current, savedTodo]);
38 });
39 } catch (error) {
40 // En cas d'erreur, l'état optimiste sera automatiquement annulé
41 console.error('Erreur lors de l’ajout de la tâche :', error);
42 }
43 }
44La tâche optimiste n'existe que pendant l'Action. Quand elle se termine, React affiche l'état todos ordinaire. Si l'enregistrement a réussi, setTodos a déjà ajouté la vraie tâche, et s'il a échoué, l'état est le même qu'avant, donc le changement « s'annule » tout seul. On enveloppe la mise à jour après await dans un autre startTransition, comme dans les exemples de la documentation. Voici la vue :
1 return (
2 <div style={{ maxWidth: '500px', margin: '0 auto', padding: '20px' }}>
3 <h2>Liste des tâches</h2>
4
5 <form
6 onSubmit={(e) => {
7 e.preventDefault();
8 const text = e.target.todo.value;
9 if (text) {
10 startTransition(() => handleAddTodo(text));
11 e.target.reset();
12 }
13 }}
14 style={{ marginBottom: '20px' }}
15 >
16 <input
17 name="todo"
18 style={{
19 width: '70%',
20 padding: '8px',
21 marginRight: '10px',
22 border: '1px solid #ddd',
23 borderRadius: '4px'
24 }}
25 />
26 <button
27 type="submit"
28 style={{
29 padding: '8px 16px',
30 backgroundColor: '#007bff',
31 color: 'white',
32 border: 'none',
33 borderRadius: '4px',
34 cursor: 'pointer'
35 }}
36 >
37 Ajouter
38 </button>
39 </form>
40
41 <ul style={{ listStyle: 'none', padding: 0 }}>
42 {optimisticTodos.map(todo => (
43 <li
44 key={todo.id}
45 style={{
46 padding: '10px',
47 marginBottom: '5px',
48 backgroundColor: todo.pending ? '#f0f8ff' : '#f8f9fa',
49 border: '1px solid #ddd',
50 borderRadius: '4px',
51 opacity: todo.pending ? 0.7 : 1,
52 transition: 'all 0.3s'
53 }}
54 >
55 <input
56 type="checkbox"
57 checked={todo.completed}
58 readOnly
59 style={{ marginRight: '10px' }}
60 />
61 <span style={{
62 textDecoration: todo.completed ? 'line-through' : 'none'
63 }}>
64 {todo.text}
65 </span>
66 {todo.pending && (
67 <span style={{
68 marginLeft: '10px',
69 color: '#007bff',
70 fontSize: '12px'
71 }}>
72 (enregistrement...)
73 </span>
74 )}
75 </li>
76 ))}
77 </ul>
78 </div>
79 );
80}Le formulaire appelle handleAddTodo à l'intérieur de startTransition, donc l'entrée optimiste marquée « (enregistrement...) » reste à l'écran jusqu'à la réponse du serveur.
2. Bascule optimiste avec gestion des erreurs
Le bouton « j'aime » est le cas classique : la réaction doit être instantanée, et le serveur la confirme en arrière-plan. Ici, la fonction de mise à jour renvoie simplement la nouvelle valeur :
1import { useOptimistic, useState, startTransition } from 'react';
2
3function LikeButton({ postId, initialLikes, initialIsLiked }) {
4 const [likes, setLikes] = useState(initialLikes);
5 const [isLiked, setIsLiked] = useState(initialIsLiked);
6
7 const [optimisticLikes, updateOptimisticLikes] = useOptimistic(
8 { count: likes, isLiked },
9 (current, optimisticValue) => optimisticValue
10 );
11
12 async function handleLikeToggle() {
13 const newIsLiked = !optimisticLikes.isLiked;
14 const newCount = optimisticLikes.count + (newIsLiked ? 1 : -1);
15
16 // Mise à jour optimiste
17 updateOptimisticLikes({
18 count: newCount,
19 isLiked: newIsLiked
20 });
21
22 try {
23 const response = await fetch(`/api/posts/${postId}/like`, {
24 method: newIsLiked ? 'POST' : 'DELETE'
25 });
26
27 if (!response.ok) {
28 throw new Error('Erreur de mise à jour');
29 }
30
31 const data = await response.json();
32
33 // Met à jour l'état réel
34 setLikes(data.likes);
35 setIsLiked(data.isLiked);
36 } catch (error) {
37 // L'état optimiste sera automatiquement annulé
38 alert('Échec de la mise à jour du like');
39 }
40 }
41
42 return (
43 <button
44 onClick={() => startTransition(handleLikeToggle)}
45 style={{
46 display: 'flex',
47 alignItems: 'center',
48 gap: '8px',
49 padding: '8px 16px',
50 backgroundColor: optimisticLikes.isLiked ? '#dc3545' : '#fff',
51 color: optimisticLikes.isLiked ? '#fff' : '#dc3545',
52 border: '1px solid #dc3545',
53 borderRadius: '20px',
54 cursor: 'pointer',
55 transition: 'all 0.3s',
56 fontSize: '16px'
57 }}
58 >
59 <span>{optimisticLikes.isLiked ? 'Aimé' : 'J’aime'}</span>
60 <span>{optimisticLikes.count}</span>
61 </button>
62 );
63}
64
65// Exemple d'utilisation dans un composant d'article
66function Post({ post }) {
67 return (
68 <article style={{
69 padding: '20px',
70 marginBottom: '20px',
71 backgroundColor: '#fff',
72 borderRadius: '8px',
73 boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
74 }}>
75 <h3>{post.title}</h3>
76 <p>{post.content}</p>
77
78 <div style={{
79 display: 'flex',
80 justifyContent: 'space-between',
81 alignItems: 'center',
82 marginTop: '15px'
83 }}>
84 <LikeButton
85 postId={post.id}
86 initialLikes={post.likes}
87 initialIsLiked={post.isLiked}
88 />
89
90 <span style={{ color: '#6c757d', fontSize: '14px' }}>
91 {new Date(post.createdAt).toLocaleDateString('fr-FR')}
92 </span>
93 </div>
94 </article>
95 );
96}Un clic lance handleLikeToggle dans startTransition. Quand le serveur renvoie une erreur, le bloc catch affiche un message, et le compteur revient à la valeur de likes, car l'état réel n'a pas changé.
3. Tri et filtrage optimistes
La fonction de mise à jour fonctionne comme un reducer : elle reçoit l'état actuel et la description du changement. Ainsi, un seul appel gère à la fois la case à cocher et le changement de priorité :
1import { useOptimistic, useState, startTransition } from 'react';
2
3function OptimisticTaskList() {
4 const [tasks, setTasks] = useState([
5 { id: 1, title: 'Tâche 1', priority: 'low', completed: false },
6 { id: 2, title: 'Tâche 2', priority: 'high', completed: false },
7 { id: 3, title: 'Tâche 3', priority: 'medium', completed: true }
8 ]);
9
10 const [optimisticTasks, updateOptimisticTask] = useOptimistic(
11 tasks,
12 (currentTasks, { id, updates }) =>
13 currentTasks.map(task =>
14 task.id === id ? { ...task, ...updates, updating: true } : task
15 )
16 );
17
18 async function handleTaskUpdate(taskId, updates) {
19 // Mise à jour optimiste
20 updateOptimisticTask({ id: taskId, updates });
21
22 try {
23 const response = await fetch(`/api/tasks/${taskId}`, {
24 method: 'PATCH',
25 headers: { 'Content-Type': 'application/json' },
26 body: JSON.stringify(updates)
27 });
28
29 const updatedTask = await response.json();
30
31 // Met à jour l'état réel
32 setTasks(current =>
33 current.map(task => task.id === taskId ? updatedTask : task)
34 );
35 } catch (error) {
36 console.error('Erreur lors de la mise à jour de la tâche :', error);
37 }
38 }
39
40 const priorityColors = {
41 low: '#28a745',
42 medium: '#ffc107',
43 high: '#dc3545'
44 };
45React relance cette fonction si le vrai tasks change pendant l'Action, donc le changement s'applique toujours sur les données actuelles. Voici la vue avec les statistiques :
1 return (
2 <div style={{ maxWidth: '600px', margin: '0 auto', padding: '20px' }}>
3 <h2>Gestion des tâches</h2>
4
5 <div style={{ marginBottom: '20px' }}>
6 <h3>Statistiques :</h3>
7 <div style={{ display: 'flex', gap: '20px', fontSize: '14px' }}>
8 <span>
9 Terminées : {optimisticTasks.filter(t => t.completed).length}
10 </span>
11 <span>
12 En cours : {optimisticTasks.filter(t => !t.completed).length}
13 </span>
14 <span>
15 Priorité haute : {optimisticTasks.filter(t => t.priority === 'high').length}
16 </span>
17 </div>
18 </div>
19
20 <div style={{ display: 'grid', gap: '10px' }}>
21 {optimisticTasks.map(task => (
22 <div
23 key={task.id}
24 style={{
25 padding: '15px',
26 backgroundColor: task.updating ? '#f0f8ff' : '#fff',
27 border: '1px solid #ddd',
28 borderRadius: '8px',
29 opacity: task.updating ? 0.8 : 1,
30 transition: 'all 0.3s'
31 }}
32 >
33 <div style={{
34 display: 'flex',
35 justifyContent: 'space-between',
36 alignItems: 'center'
37 }}>
38 <div style={{ flex: 1 }}>
39 <input
40 type="checkbox"
41 checked={task.completed}
42 onChange={(e) =>
43 startTransition(() => handleTaskUpdate(task.id, { completed: e.target.checked }))
44 }
45 style={{ marginRight: '10px' }}
46 />
47 <span style={{
48 textDecoration: task.completed ? 'line-through' : 'none',
49 color: task.completed ? '#6c757d' : '#000'
50 }}>
51 {task.title}
52 </span>
53 </div>
54
55 <select
56 value={task.priority}
57 onChange={(e) =>
58 startTransition(() => handleTaskUpdate(task.id, { priority: e.target.value }))
59 }
60 style={{
61 padding: '4px 8px',
62 border: `1px solid ${priorityColors[task.priority]}`,
63 borderRadius: '4px',
64 color: priorityColors[task.priority],
65 backgroundColor: 'transparent',
66 cursor: 'pointer'
67 }}
68 >
69 <option value="low">Basse</option>
70 <option value="medium">Moyenne</option>
71 <option value="high">Haute</option>
72 </select>
73
74 {task.updating && (
75 <span style={{
76 marginLeft: '10px',
77 color: '#007bff',
78 fontSize: '12px'
79 }}>
80 Enregistrement...
81 </span>
82 )}
83 </div>
84 </div>
85 ))}
86 </div>
87 </div>
88 );
89}On calcule les statistiques à partir de optimisticTasks, donc les compteurs changent en même temps que la case à cocher, sans attendre le serveur.
Patterns avancés avec useOptimistic
1. Suppression optimiste avec animation
Cette fois, l'état optimiste ne supprime pas l'élément, il le marque avec un drapeau deleting, ce qui laisse le temps à une animation :
1import { useOptimistic, useState, startTransition } from 'react';
2
3function OptimisticDeleteList() {
4 const [items, setItems] = useState([
5 { id: 1, name: 'Élément 1', createdAt: new Date() },
6 { id: 2, name: 'Élément 2', createdAt: new Date() },
7 { id: 3, name: 'Élément 3', createdAt: new Date() }
8 ]);
9
10 const [optimisticItems, removeOptimisticItem] = useOptimistic(
11 items,
12 (currentItems, removedId) =>
13 currentItems.map(item =>
14 item.id === removedId ? { ...item, deleting: true } : item
15 )
16 );
17
18 async function handleDelete(itemId) {
19 // Marque de manière optimiste comme en cours de suppression
20 removeOptimisticItem(itemId);
21
22 // Animation avant la suppression
23 await new Promise(resolve => setTimeout(resolve, 300));
24
25 try {
26 const response = await fetch(`/api/items/${itemId}`, {
27 method: 'DELETE'
28 });
29
30 if (!response.ok) {
31 throw new Error('Erreur de suppression');
32 }
33
34 // Supprime réellement de l'état
35 setItems(current => current.filter(item => item.id !== itemId));
36 } catch (error) {
37 // Annulation du changement optimiste
38 alert('Échec de la suppression de l’élément');
39 }
40 }
41
42 return (
43 <div style={{ maxWidth: '500px', margin: '0 auto', padding: '20px' }}>
44 <h2>Liste avec suppression optimiste</h2>
45
46 <div style={{ display: 'grid', gap: '10px' }}>
47 {optimisticItems
48 .map(item => (
49 <div
50 key={item.id}
51 style={{
52 padding: '15px',
53 backgroundColor: '#fff',
54 border: '1px solid #ddd',
55 borderRadius: '8px',
56 display: 'flex',
57 justifyContent: 'space-between',
58 alignItems: 'center',
59 opacity: item.deleting ? 0 : 1,
60 transform: item.deleting ? 'translateX(-100%)' : 'translateX(0)',
61 transition: 'all 0.3s ease-out',
62 overflow: 'hidden'
63 }}
64 >
65 <div>
66 <h4 style={{ margin: '0 0 5px 0' }}>{item.name}</h4>
67 <small style={{ color: '#6c757d' }}>
68 Créé le : {item.createdAt.toLocaleString('fr-FR')}
69 </small>
70 </div>
71
72 <button
73 onClick={() => startTransition(() => handleDelete(item.id))}
74 disabled={item.deleting}
75 style={{
76 padding: '5px 10px',
77 backgroundColor: '#dc3545',
78 color: 'white',
79 border: 'none',
80 borderRadius: '4px',
81 cursor: item.deleting ? 'not-allowed' : 'pointer',
82 opacity: item.deleting ? 0.5 : 1
83 }}
84 >
85 {item.deleting ? 'Suppression...' : 'Supprimer'}
86 </button>
87 </div>
88 ))}
89 </div>
90 </div>
91 );
92}Le délai de l'animation est maintenant un simple await à l'intérieur de l'Action. Si la requête se trouvait dans un setTimeout, l'Action se terminerait aussitôt et le drapeau deleting disparaîtrait avant la suppression.
2. Collaboration optimiste en temps réel
L'éditeur partagé combine deux états optimistes avec une connexion WebSocket. Considère-le comme une esquisse d'architecture :
1import { useOptimistic, useState, useEffect, startTransition } from 'react';
2
3function CollaborativeEditor({ documentId, userId }) {
4 const [content, setContent] = useState('');
5 const [collaborators, setCollaborators] = useState([]);
6
7 const [optimisticContent, updateOptimisticContent] = useOptimistic(
8 content,
9 (_, newContent) => newContent
10 );
11
12 const [optimisticCollaborators, updateOptimisticCollaborators] = useOptimistic(
13 collaborators,
14 (currentCollaborators, update) => {
15 if (update.type === 'cursor') {
16 return currentCollaborators.map(c =>
17 c.id === update.userId ? { ...c, cursor: update.position } : c
18 );
19 }
20 return currentCollaborators;
21 }
22 );
23
24 // WebSocket ou autre connexion en temps réel
25 useEffect(() => {
26 const ws = new WebSocket(`ws://localhost:8080/doc/${documentId}`);
27
28 ws.onmessage = (event) => {
29 const data = JSON.parse(event.data);
30
31 if (data.type === 'content-update' && data.userId !== userId) {
32 setContent(data.content);
33 } else if (data.type === 'cursor-update') {
34 setCollaborators(data.collaborators);
35 }
36 };
37
38 return () => ws.close();
39 }, [documentId, userId]);
40
41 async function handleContentChange(newContent) {
42 // Mise à jour optimiste
43 updateOptimisticContent(newContent);
44
45 try {
46 await fetch(`/api/documents/${documentId}`, {
47 method: 'PATCH',
48 headers: { 'Content-Type': 'application/json' },
49 body: JSON.stringify({ content: newContent, userId })
50 });
51
52 setContent(newContent);
53 } catch (error) {
54 console.error('Erreur lors de l’enregistrement :', error);
55 }
56 }
57
58 function handleCursorMove(position) {
59 updateOptimisticCollaborators({
60 type: 'cursor',
61 userId,
62 position
63 });
64
65 // Envoie la mise à jour du curseur via WebSocket
66 // ws.send(JSON.stringify({ type: 'cursor', position }));
67 }
68
69 return (
70 <div style={{ maxWidth: '800px', margin: '0 auto', padding: '20px' }}>
71 <h2>Éditeur partagé</h2>
72
73 <div style={{ marginBottom: '10px' }}>
74 <h4>Utilisateurs actifs :</h4>
75 <div style={{ display: 'flex', gap: '10px' }}>
76 {optimisticCollaborators.map(collaborator => (
77 <div
78 key={collaborator.id}
79 style={{
80 padding: '5px 10px',
81 backgroundColor: collaborator.color,
82 color: 'white',
83 borderRadius: '15px',
84 fontSize: '12px'
85 }}
86 >
87 {collaborator.name}
88 {collaborator.cursor && (
89 <span style={{ marginLeft: '5px' }}>
90 (ligne {collaborator.cursor.line})
91 </span>
92 )}
93 </div>
94 ))}
95 </div>
96 </div>
97
98 <textarea
99 value={optimisticContent}
100 onChange={(e) => startTransition(() => handleContentChange(e.target.value))}
101 onSelect={(e) => {
102 const position = {
103 line: e.target.value.substring(0, e.target.selectionStart).split('\n').length,
104 column: e.target.selectionStart
105 };
106 startTransition(() => handleCursorMove(position));
107 }}
108 style={{
109 width: '100%',
110 height: '400px',
111 padding: '15px',
112 border: '1px solid #ddd',
113 borderRadius: '8px',
114 fontSize: '16px',
115 fontFamily: 'monospace'
116 }}
117 />
118
119 <div style={{
120 marginTop: '10px',
121 fontSize: '12px',
122 color: '#6c757d'
123 }}>
124 Dernière synchro : {new Date().toLocaleTimeString('fr-FR')}
125 </div>
126 </div>
127 );
128}Le curseur réagit à l'événement onSelect, car React n'a pas d'événement onSelectionChange pour un champ de texte. Deux mises en garde : react.dev déconseille de contrôler un champ de texte via des transitions, donc le texte tapé par l'utilisateur est mieux gardé dans un état ordinaire. La position du curseur, elle, n'attend pas le serveur, donc un simple useState lui convient mieux qu'un état optimiste.
3. Mises à jour groupées avec file d'attente
Un chat peut envoyer les messages par lots de cinq. D'abord la file d'attente et son traitement :
1import { useOptimistic, useState, useRef, startTransition } from 'react';
2
3function BatchedUpdatesDemo() {
4 const [messages, setMessages] = useState([]);
5 const updateQueue = useRef([]);
6 const isProcessing = useRef(false);
7
8 const [optimisticMessages, addOptimisticMessage] = useOptimistic(
9 messages,
10 (currentMessages, newMessage) => [...currentMessages, newMessage]
11 );
12
13 async function processBatch() {
14 if (isProcessing.current || updateQueue.current.length === 0) return;
15
16 isProcessing.current = true;
17 const batch = updateQueue.current.splice(0, 5); // Max 5 à la fois
18
19 try {
20 const response = await fetch('/api/messages/batch', {
21 method: 'POST',
22 headers: { 'Content-Type': 'application/json' },
23 body: JSON.stringify({ messages: batch })
24 });
25
26 const savedMessages = await response.json();
27
28 setMessages(current => [
29 ...current,
30 ...savedMessages.map(msg => ({ ...msg, sent: true }))
31 ]);
32 } catch (error) {
33 console.error('Erreur lors de l’envoi du lot :', error);
34 // Remet dans la file d'attente
35 updateQueue.current.unshift(...batch);
36 } finally {
37 isProcessing.current = false;
38 // Traite le lot suivant s'il reste des éléments
39 if (updateQueue.current.length > 0) {
40 setTimeout(processBatch, 100);
41 }
42 }
43 }
44
45 async function sendMessage(text) {
46 const newMessage = {
47 id: Date.now(),
48 text,
49 timestamp: new Date(),
50 sent: false,
51 pending: true
52 };
53
54 // Mise à jour optimiste
55 addOptimisticMessage(newMessage);
56
57 // Ajoute à la file d'attente
58 updateQueue.current.push(newMessage);
59
60 // Démarre le traitement s'il n'est pas actif
61 await processBatch();
62 }
63useRef garde la file en dehors de l'état, donc ses changements ne déclenchent pas de rendu. Voici l'envoi et la vue :
1 return (
2 <div style={{ maxWidth: '600px', margin: '0 auto', padding: '20px' }}>
3 <h2>Chat avec mises à jour groupées</h2>
4
5 <div style={{
6 height: '400px',
7 overflowY: 'auto',
8 border: '1px solid #ddd',
9 borderRadius: '8px',
10 padding: '15px',
11 marginBottom: '15px',
12 backgroundColor: '#f8f9fa'
13 }}>
14 {optimisticMessages.map(message => (
15 <div
16 key={message.id}
17 style={{
18 marginBottom: '10px',
19 padding: '10px',
20 backgroundColor: message.pending ? '#e3f2fd' : '#fff',
21 borderRadius: '8px',
22 opacity: message.pending ? 0.8 : 1,
23 transition: 'all 0.3s'
24 }}
25 >
26 <div style={{ marginBottom: '5px' }}>{message.text}</div>
27 <div style={{
28 fontSize: '12px',
29 color: '#6c757d',
30 display: 'flex',
31 justifyContent: 'space-between'
32 }}>
33 <span>{message.timestamp.toLocaleTimeString('fr-FR')}</span>
34 <span>
35 {message.pending ? 'Envoi...' : 'Envoyé'}
36 </span>
37 </div>
38 </div>
39 ))}
40 </div>
41
42 <form
43 onSubmit={(e) => {
44 e.preventDefault();
45 const input = e.target.message;
46 if (input.value) {
47 startTransition(() => sendMessage(input.value));
48 input.value = '';
49 }
50 }}
51 style={{ display: 'flex', gap: '10px' }}
52 >
53 <input
54 name="message"
55 style={{
56 flex: 1,
57 padding: '10px',
58 border: '1px solid #ddd',
59 borderRadius: '4px'
60 }}
61 />
62 <button
63 type="submit"
64 style={{
65 padding: '10px 20px',
66 backgroundColor: '#007bff',
67 color: 'white',
68 border: 'none',
69 borderRadius: '4px',
70 cursor: 'pointer'
71 }}
72 >
73 Envoyer
74 </button>
75 </form>
76
77 <div style={{
78 marginTop: '10px',
79 fontSize: '12px',
80 color: '#6c757d'
81 }}>
82 En file d'attente : {updateQueue.current.length} |
83 Traitement : {isProcessing.current ? 'Oui' : 'Non'}
84 </div>
85 </div>
86 );
87}sendMessage attend processBatch, donc le message reste optimiste jusqu'à la fin de l'envoi. Attention : quand un lot est déjà en route, processBatch revient immédiatement, et le message suivant peut disparaître avant d'être envoyé. Le compteur de la file lit aussi un ref, donc il ne se rafraîchit pas tout seul.
Applications pratiques de useOptimistic
1. Système de commentaires avec réactions
Un seul reducer gère trois types de changements : l'ajout d'un commentaire, une réaction et une réponse. D'abord la logique :
1import { useOptimistic, useState, startTransition } from 'react';
2
3function CommentSystem({ postId }) {
4 const [comments, setComments] = useState([]);
5
6 const [optimisticComments, updateOptimisticComment] = useOptimistic(
7 comments,
8 (currentComments, action) => {
9 switch (action.type) {
10 case 'add':
11 return [...currentComments, action.comment];
12 case 'react':
13 return currentComments.map(comment =>
14 comment.id === action.commentId
15 ? {
16 ...comment,
17 reactions: {
18 ...comment.reactions,
19 [action.reaction]: (comment.reactions[action.reaction] || 0) + 1
20 },
21 userReacted: action.reaction
22 }
23 : comment
24 );
25 case 'reply':
26 return currentComments.map(comment =>
27 comment.id === action.parentId
28 ? {
29 ...comment,
30 replies: [...(comment.replies || []), action.reply]
31 }
32 : comment
33 );
34 default:
35 return currentComments;
36 }
37 }
38 );
39
40 async function addComment(text) {
41 const newComment = {
42 id: Date.now(),
43 text,
44 author: 'Toi',
45 timestamp: new Date(),
46 reactions: {},
47 replies: [],
48 pending: true
49 };
50
51 updateOptimisticComment({ type: 'add', comment: newComment });
52
53 try {
54 const response = await fetch(`/api/posts/${postId}/comments`, {
55 method: 'POST',
56 headers: { 'Content-Type': 'application/json' },
57 body: JSON.stringify({ text })
58 });
59
60 const savedComment = await response.json();
61 setComments(current => [...current, savedComment]);
62 } catch (error) {
63 console.error('Erreur lors de l’ajout du commentaire :', error);
64 }
65 }
66
67 async function reactToComment(commentId, reaction) {
68 updateOptimisticComment({
69 type: 'react',
70 commentId,
71 reaction
72 });
73
74 try {
75 await fetch(`/api/comments/${commentId}/react`, {
76 method: 'POST',
77 headers: { 'Content-Type': 'application/json' },
78 body: JSON.stringify({ reaction })
79 });
80
81 // Met à jour l'état réel
82 const response = await fetch(`/api/posts/${postId}/comments`);
83 const updatedComments = await response.json();
84 setComments(updatedComments);
85 } catch (error) {
86 console.error('Erreur lors de la réaction :', error);
87 }
88 }
89
90 const reactionLabels = {
91 like: 'J’aime',
92 love: 'Adore',
93 laugh: 'Haha',
94 wow: 'Waouh',
95 sad: 'Triste'
96 };
97Chaque changement passe par updateOptimisticComment avec un champ type, comme les actions de useReducer. Voici la vue :
1 return (
2 <div style={{ maxWidth: '600px', margin: '0 auto', padding: '20px' }}>
3 <h3>Commentaires</h3>
4
5 <form
6 onSubmit={(e) => {
7 e.preventDefault();
8 const text = e.target.comment.value;
9 if (text) {
10 startTransition(() => addComment(text));
11 e.target.reset();
12 }
13 }}
14 style={{ marginBottom: '20px' }}
15 >
16 <textarea
17 name="comment"
18 rows="3"
19 style={{
20 width: '100%',
21 padding: '10px',
22 border: '1px solid #ddd',
23 borderRadius: '4px',
24 marginBottom: '10px'
25 }}
26 />
27 <button
28 type="submit"
29 style={{
30 padding: '8px 16px',
31 backgroundColor: '#007bff',
32 color: 'white',
33 border: 'none',
34 borderRadius: '4px',
35 cursor: 'pointer'
36 }}
37 >
38 Ajouter un commentaire
39 </button>
40 </form>
41
42 <div style={{ display: 'grid', gap: '15px' }}>
43 {optimisticComments.map(comment => (
44 <div
45 key={comment.id}
46 style={{
47 padding: '15px',
48 backgroundColor: comment.pending ? '#f0f8ff' : '#fff',
49 border: '1px solid #ddd',
50 borderRadius: '8px',
51 opacity: comment.pending ? 0.8 : 1
52 }}
53 >
54 <div style={{ marginBottom: '10px' }}>
55 <strong>{comment.author}</strong>
56 <span style={{
57 marginLeft: '10px',
58 color: '#6c757d',
59 fontSize: '14px'
60 }}>
61 {comment.timestamp.toLocaleString('fr-FR')}
62 </span>
63 {comment.pending && (
64 <span style={{
65 marginLeft: '10px',
66 color: '#007bff',
67 fontSize: '12px'
68 }}>
69 (envoi...)
70 </span>
71 )}
72 </div>
73
74 <p style={{ margin: '10px 0' }}>{comment.text}</p>
75
76 <div style={{ display: 'flex', gap: '10px' }}>
77 {Object.entries(reactionLabels).map(([reaction, label]) => (
78 <button
79 key={reaction}
80 onClick={() => startTransition(() => reactToComment(comment.id, reaction))}
81 style={{
82 padding: '4px 8px',
83 backgroundColor: comment.userReacted === reaction ? '#e3f2fd' : '#f8f9fa',
84 border: '1px solid #ddd',
85 borderRadius: '15px',
86 cursor: 'pointer',
87 fontSize: '14px',
88 display: 'flex',
89 alignItems: 'center',
90 gap: '4px'
91 }}
92 >
93 <span>{label}</span>
94 {comment.reactions[reaction] > 0 && (
95 <span>{comment.reactions[reaction]}</span>
96 )}
97 </button>
98 ))}
99 </div>
100
101 {comment.replies && comment.replies.length > 0 && (
102 <div style={{
103 marginTop: '15px',
104 marginLeft: '20px',
105 paddingLeft: '15px',
106 borderLeft: '2px solid #e9ecef'
107 }}>
108 {comment.replies.map(reply => (
109 <div key={reply.id} style={{ marginBottom: '10px' }}>
110 <strong>{reply.author} :</strong> {reply.text}
111 </div>
112 ))}
113 </div>
114 )}
115 </div>
116 ))}
117 </div>
118 </div>
119 );
120}Les boutons de réaction et le formulaire lancent leurs fonctions dans startTransition, donc les compteurs augmentent instantanément.
2. Drag & Drop avec tri optimiste
Le glisser-déposer est un autre cas où l'utilisateur ne peut pas attendre le serveur :
1import { useOptimistic, useState, startTransition } from 'react';
2
3function DragDropList() {
4 const [items, setItems] = useState([
5 { id: 1, text: 'Élément 1', order: 0 },
6 { id: 2, text: 'Élément 2', order: 1 },
7 { id: 3, text: 'Élément 3', order: 2 },
8 { id: 4, text: 'Élément 4', order: 3 }
9 ]);
10
11 const [draggedItem, setDraggedItem] = useState(null);
12
13 const [optimisticItems, reorderOptimistic] = useOptimistic(
14 items,
15 (currentItems, { fromIndex, toIndex }) => {
16 const newItems = [...currentItems];
17 const [removed] = newItems.splice(fromIndex, 1);
18 newItems.splice(toIndex, 0, removed);
19 return newItems.map((item, index) => ({ ...item, order: index }));
20 }
21 );
22
23 async function handleDrop(fromIndex, toIndex) {
24 if (fromIndex === toIndex) return;
25
26 // Mise à jour optimiste
27 reorderOptimistic({ fromIndex, toIndex });
28
29 try {
30 const response = await fetch('/api/items/reorder', {
31 method: 'POST',
32 headers: { 'Content-Type': 'application/json' },
33 body: JSON.stringify({ fromIndex, toIndex })
34 });
35
36 const reorderedItems = await response.json();
37 setItems(reorderedItems);
38 } catch (error) {
39 console.error('Erreur lors du réordonnancement :', error);
40 }
41 }
42
43 return (
44 <div style={{ maxWidth: '400px', margin: '0 auto', padding: '20px' }}>
45 <h2>Liste Drag & Drop</h2>
46
47 <div style={{
48 border: '1px solid #ddd',
49 borderRadius: '8px',
50 overflow: 'hidden'
51 }}>
52 {optimisticItems.map((item, index) => (
53 <div
54 key={item.id}
55 draggable
56 onDragStart={() => setDraggedItem({ item, index })}
57 onDragOver={(e) => e.preventDefault()}
58 onDrop={() => {
59 if (draggedItem) {
60 startTransition(() => handleDrop(draggedItem.index, index));
61 setDraggedItem(null);
62 }
63 }}
64 style={{
65 padding: '15px',
66 backgroundColor: '#fff',
67 borderBottom: '1px solid #e9ecef',
68 cursor: 'move',
69 display: 'flex',
70 alignItems: 'center',
71 transition: 'all 0.3s',
72 opacity: draggedItem?.item.id === item.id ? 0.5 : 1
73 }}
74 >
75 <span style={{
76 marginRight: '15px',
77 fontSize: '20px',
78 color: '#6c757d'
79 }}>
80 ≡
81 </span>
82 <span style={{ flex: 1 }}>{item.text}</span>
83 <span style={{
84 color: '#6c757d',
85 fontSize: '14px'
86 }}>
87 #{item.order + 1}
88 </span>
89 </div>
90 ))}
91 </div>
92
93 <p style={{
94 marginTop: '15px',
95 fontSize: '14px',
96 color: '#6c757d',
97 textAlign: 'center'
98 }}>
99 Fais glisser les éléments pour changer l'ordre
100 </p>
101 </div>
102 );
103}Le nouvel ordre apparaît dès le dépôt, et le serveur ne fait que le confirmer.
Bonnes pratiques avec useOptimistic
1. Structure des données
Marque les données optimistes pour que l'interface puisse les mettre en évidence :
1// BON - marquer l'état optimiste
2const optimisticItem = {
3 ...originalItem,
4 optimistic: true, // ou pending: true
5 optimisticId: Date.now() // pour l'identification
6};
7
8// BON - préserver les données originales
9const [optimistic, update] = useOptimistic(
10 realData,
11 (current, change) => ({
12 ...current,
13 ...change,
14 _original: current // conserve l'original
15 })
16);
17
18// MAUVAIS - aucun marquage de l'état
19const badOptimistic = {
20 ...originalItem
21 // Impossible de distinguer des données réelles
22};Un drapeau pending ou optimistic permet d'afficher « envoi » et de bloquer les actions sur les éléments non confirmés.
2. Gestion des erreurs
Les nouvelles tentatives avec un délai exponentiel conviennent aux pannes réseau passagères :
1// Stratégie de réessai avec backoff exponentiel
2async function optimisticUpdateWithRetry(action, maxRetries = 3) {
3 let lastError;
4
5 for (let i = 0; i < maxRetries; i++) {
6 try {
7 const result = await action();
8 return result;
9 } catch (error) {
10 lastError = error;
11
12 if (i < maxRetries - 1) {
13 // Backoff exponentiel
14 await new Promise(resolve =>
15 setTimeout(resolve, Math.pow(2, i) * 1000)
16 );
17 }
18 }
19 }
20
21 // Affiche l'erreur à l'utilisateur
22 showErrorNotification('L’opération a échoué. Réessaie.');
23 throw lastError;
24}Souviens-toi que l'état optimiste dure pendant toutes les tentatives, car toute la fonction s'exécute dans une seule Action.
3. Performance
Pour finir, deux patterns de performance :
1// BON - mises à jour groupées
2const [optimistic, batchUpdate] = useOptimistic(
3 items,
4 (current, updates) => {
5 // Traite plusieurs mises à jour d'un coup
6 return updates.reduce((acc, update) => {
7 // Applique chaque mise à jour
8 return applyUpdate(acc, update);
9 }, current);
10 }
11);
12
13// Utilisation
14function handleMultipleUpdates(updates) {
15 batchUpdate(updates); // Une seule mise à jour au lieu de plusieurs
16}
17
18// BON - mémoïsation des calculs lourds
19const processedOptimisticData = useMemo(() => {
20 return expensiveProcessing(optimisticData);
21}, [optimisticData]);Un seul appel avec un tableau de changements donne un seul passage du reducer au lieu de plusieurs.
Le hook useOptimistic est la clé pour créer des applications réactives qui donnent à l'utilisateur un retour instantané. Une utilisation correcte de ce hook peut améliorer considérablement la performance perçue d'une application.
Mon conseil : le plus simple est d'utiliser useOptimistic avec une action de formulaire, car React l'enveloppe alors lui-même dans une transition. Dans le projet de ce module, tu combineras ce hook avec useActionState. Retiens : l'optimisme sur la passerelle ne dure que jusqu'au rapport de Mars, ensuite seul l'état réel compte.
Code de cette leçon : App.jsx
1import { useState, useOptimistic, useTransition } from 'react';
2
3// Simulation d’API : réponses du serveur retardées
4function simulateAPI(action, data) {
5 return new Promise((resolve, reject) => {
6 setTimeout(() => {
7 // 10 % de risque d’erreur
8 if (Math.random() < 0.1) {
9 reject(new Error(`Erreur du serveur pendant : ${action}`));
10 } else {
11 resolve({ success: true, data });
12 }
13 }, 1000 + Math.random() * 1500);
14 });
15}
16
17function MissionCard({ mission, onToggle, onDelete }) {
18 return (
19 <div style={{
20 padding: '14px 16px', marginBottom: '10px',
21 background: mission.sending
22 ? 'rgba(0, 212, 255, 0.06)'
23 : mission.completed
24 ? 'rgba(78, 205, 196, 0.08)'
25 : 'rgba(255, 255, 255, 0.03)',
26 borderRadius: '10px',
27 border: `1px solid ${mission.sending ? '#1a3a5c' : mission.completed ? '#2a5c3a' : '#1a2a3c'}`,
28 opacity: mission.sending ? 0.7 : 1,
29 transition: 'all 0.3s ease',
30 display: 'flex', alignItems: 'center', gap: '12px',
31 }}>
32 <button
33 onClick={() => onToggle(mission.id)}
34 disabled={mission.sending}
35 aria-pressed={mission.completed}
36 style={{
37 width: '28px', height: '28px', borderRadius: '50%',
38 border: `2px solid ${mission.completed ? '#4ecdc4' : '#778da9'}`,
39 background: mission.completed ? '#4ecdc4' : 'transparent',
40 cursor: mission.sending ? 'default' : 'pointer',
41 display: 'flex', alignItems: 'center', justifyContent: 'center',
42 color: '#fff', fontSize: '14px', flexShrink: 0,
43 }}
44 >
45 {mission.completed ? '√' : ''}
46 </button>
47
48 <div style={{ flex: 1 }}>
49 <div style={{
50 color: mission.completed ? '#4ecdc4' : '#e0e1dd',
51 textDecoration: mission.completed ? 'line-through' : 'none',
52 fontSize: '15px', fontWeight: 500,
53 }}>
54 {mission.name}
55 </div>
56 <div style={{ color: '#556677', fontSize: '12px', marginTop: '2px' }}>
57 {mission.priority}
58 </div>
59 </div>
60
61 {mission.sending && (
62 <span style={{ color: '#00d4ff', fontSize: '11px', fontStyle: 'italic' }}>
63 Synchronisation...
64 </span>
65 )}
66
67 <button
68 onClick={() => onDelete(mission.id)}
69 disabled={mission.sending}
70 style={{
71 padding: '4px 8px', background: 'transparent',
72 border: '1px solid #5c1a1a', borderRadius: '4px',
73 color: '#ff6b6b', cursor: mission.sending ? 'default' : 'pointer',
74 fontSize: '12px',
75 }}
76 >
77 Supprimer
78 </button>
79 </div>
80 );
81}
82
83export default function App() {
84 const [missions, setMissions] = useState([
85 { id: 1, name: 'Explorer l’orbite de Mars', completed: false, priority: 'Haute' },
86 { id: 2, name: 'Réparer le module de communication', completed: true, priority: 'Critique' },
87 { id: 3, name: 'Collecter des échantillons sur Io', completed: false, priority: 'Moyenne' },
88 { id: 4, name: 'Calibrer le télescope', completed: false, priority: 'Basse' },
89 ]);
90
91 const [newMission, setNewMission] = useState('');
92 const [error, setError] = useState(null);
93 const [isPending, startTransition] = useTransition();
94
95 // useOptimistic - mises à jour instantanées de l’UI
96 const [optimisticMissions, addOptimistic] = useOptimistic(
97 missions,
98 // Réducteur : (état actuel, changement optimiste) => nouvel état optimiste
99 (currentMissions, optimisticUpdate) => {
100 switch (optimisticUpdate.type) {
101 case 'toggle':
102 return currentMissions.map(m =>
103 m.id === optimisticUpdate.id
104 ? { ...m, completed: !m.completed, sending: true }
105 : m
106 );
107 case 'add':
108 return [...currentMissions, { ...optimisticUpdate.mission, sending: true }];
109 case 'delete':
110 return currentMissions.map(m =>
111 m.id === optimisticUpdate.id ? { ...m, sending: true } : m
112 );
113 default:
114 return currentMissions;
115 }
116 }
117 );
118
119 // Chaque changement est une Action dans startTransition : l’état optimiste dure jusqu’à la fin de la fonction asynchrone
120 function handleToggle(id) {
121 setError(null);
122 startTransition(async () => {
123 addOptimistic({ type: 'toggle', id });
124 try {
125 await simulateAPI('toggle', { id });
126 // On enveloppe la mise à jour après await dans un autre startTransition, comme le conseille react.dev
127 startTransition(() => {
128 setMissions(prev =>
129 prev.map(m => m.id === id ? { ...m, completed: !m.completed } : m)
130 );
131 });
132 } catch (err) {
133 // Erreur : on ne change rien, l’état optimiste disparaît tout seul à la fin de l’Action
134 setError(`Impossible de changer le statut de la mission (ID : ${id})`);
135 }
136 });
137 }
138
139 function handleAdd(e) {
140 e.preventDefault();
141 const name = newMission.trim();
142 if (!name) return;
143 setError(null);
144 setNewMission('');
145
146 const tempMission = { id: Date.now(), name, completed: false, priority: 'Moyenne' };
147 startTransition(async () => {
148 addOptimistic({ type: 'add', mission: tempMission });
149 try {
150 await simulateAPI('add', tempMission);
151 startTransition(() => {
152 setMissions(prev => [...prev, tempMission]);
153 });
154 } catch (err) {
155 setError('Impossible d’ajouter la mission');
156 }
157 });
158 }
159
160 function handleDelete(id) {
161 setError(null);
162 startTransition(async () => {
163 addOptimistic({ type: 'delete', id });
164 try {
165 await simulateAPI('delete', { id });
166 startTransition(() => {
167 setMissions(prev => prev.filter(m => m.id !== id));
168 });
169 } catch (err) {
170 setError(`Impossible de supprimer la mission (ID : ${id})`);
171 }
172 });
173 }
174
175 const completed = optimisticMissions.filter(m => m.completed).length;
176 const total = optimisticMissions.length;
177
178 return (
179 <div style={{
180 minHeight: '100vh',
181 background: 'linear-gradient(135deg, #0a0e27 0%, #1a1a3e 100%)',
182 padding: '24px', fontFamily: 'monospace',
183 }}>
184 <h1 style={{ color: '#00d4ff', textAlign: 'center', marginBottom: '4px' }}>
185 useOptimistic - Missions spatiales
186 </h1>
187 <p style={{ color: '#778da9', textAlign: 'center', marginBottom: '24px', fontSize: '13px' }}>
188 Changements visibles immédiatement, synchronisation en arrière-plan
189 </p>
190
191 <div style={{ maxWidth: '550px', margin: '0 auto' }}>
192 <div style={{
193 marginBottom: '20px', padding: '14px',
194 background: 'rgba(0, 212, 255, 0.06)', borderRadius: '10px',
195 }}>
196 <div style={{
197 display: 'flex', justifyContent: 'space-between',
198 marginBottom: '8px', fontSize: '13px', color: '#c0c8d0',
199 }}>
200 <span>Progression des missions {isPending && <em style={{ color: '#00d4ff' }}>(synchronisation en cours)</em>}</span>
201 <span>{completed}/{total} terminées</span>
202 </div>
203 <div style={{
204 height: '8px', background: '#1a2a3c', borderRadius: '4px', overflow: 'hidden',
205 }}>
206 <div style={{
207 height: '100%', width: `${total > 0 ? (completed / total) * 100 : 0}%`,
208 background: 'linear-gradient(90deg, #00d4ff, #4ecdc4)',
209 borderRadius: '4px', transition: 'width 0.3s ease',
210 }} />
211 </div>
212 </div>
213
214 <form onSubmit={handleAdd} style={{
215 display: 'flex', gap: '8px', marginBottom: '20px',
216 }}>
217 <input
218 value={newMission}
219 onChange={e => setNewMission(e.target.value)}
220 placeholder="Nouvelle mission..."
221 style={{
222 flex: 1, padding: '10px 14px', background: '#0d1b2a',
223 border: '1px solid #1a3a5c', borderRadius: '8px',
224 color: '#e0e1dd', fontSize: '14px',
225 }}
226 />
227 <button type="submit" style={{
228 padding: '10px 20px', background: '#1a3a5c',
229 border: 'none', borderRadius: '8px', color: '#00d4ff',
230 cursor: 'pointer', fontSize: '13px', whiteSpace: 'nowrap',
231 }}>
232 + Ajouter
233 </button>
234 </form>
235
236 {error && (
237 <div role="alert" style={{
238 padding: '10px 14px', marginBottom: '12px',
239 background: 'rgba(255, 107, 107, 0.1)', borderRadius: '8px',
240 border: '1px solid #5c1a1a', color: '#ff6b6b', fontSize: '13px',
241 }}>
242 {error}
243 </div>
244 )}
245
246 {optimisticMissions.map(mission => (
247 <MissionCard
248 key={mission.id}
249 mission={mission}
250 onToggle={handleToggle}
251 onDelete={handleDelete}
252 />
253 ))}
254
255 <div style={{
256 marginTop: '20px', padding: '14px',
257 background: 'rgba(255, 215, 0, 0.06)', borderRadius: '10px',
258 fontSize: '12px', color: '#c0c8d0', lineHeight: '1.7',
259 }}>
260 <strong style={{ color: '#ffd700' }}>Comment fonctionne useOptimistic :</strong>
261 <ul style={{ margin: '8px 0', paddingLeft: '18px' }}>
262 <li>L’UI se met à jour immédiatement (de façon optimiste)</li>
263 <li>La synchronisation avec le serveur se fait en arrière-plan (1 à 2,5 s)</li>
264 <li>En cas d’erreur (10 % de risque), l’état optimiste disparaît et le vrai état revient</li>
265 <li>L’indicateur « Synchronisation... » signale qu’une Action est en cours</li>
266 </ul>
267 </div>
268 </div>
269 </div>
270 );
271}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. À quoi sert le hook useTransition dans React ?
2. Que se passe-t-il avec une mise à jour optimiste de l'UI (useOptimistic) lorsque la requête au serveur renvoie une erreur ?
Voici 2 des 9 questions de cette leçon. Réponds aux autres dans le jeu.
Tâches pratiques dans le jeu
- Tri vertical
Ordonne le code d'un composant qui relie un label à un champ avec useId :
- Tri vertical
Reconstitue la syntaxe correcte pour utiliser useTransition :
- Éditeur de code
Le chat avec la base Luna envoie les messages par l'action de formulaire sendMessage. La liaison répond après 0,6 s, et un message qui commence par ! simule des parasites et se termine par une erreur. Complète les trous : ___BLANK1___ est le hook de React 19 qui crée un état optimiste à partir de la vraie liste messages et d'une fonction qui ajoute un message, ___BLANK2___ est la fonction qui affiche le message tout de suite, avant la réponse de la base, et ___BLANK3___ est la liste que tu affiches dans le <ul>. Un message envoyé doit apparaître immédiatement avec une mention d'envoi, rester sans cette mention après la confirmation, et en cas d'erreur disparaître en laissant un message.
- Tri vertical
Ordonne les étapes d'un cycle de rendu de React :
- Tri vertical
Ordonne les étapes du chargement de données avec le hook use() et Suspense :
- Clique dans l'ordre
Reconstitue la syntaxe du hook useCallback pour stabiliser les références de fonctions :
- Tri vertical
Par quoi commence l'optimisation des performances ? Ordonne les étapes : la première est fixe, l'ordre des techniques qui suivent dépend de ce que montre la mesure.
- Tri horizontal
Reconstitue la syntaxe de déstructuration du hook useOptimistic :
- Tri vertical
Ordonne les étapes du pipeline d'optimisation du build, de l'analyse à la livraison :
- Éditeur de code
Un analyseur de bundle (par exemple rollup-plugin-visualizer dans Vite) a enregistré la taille des modules de l'application après compression. Complète le rapport : ___BLANK1___ est la taille d'un module (un champ de l'objet item) que reduce ajoute à la somme, ___BLANK2___ est l'expression de comparaison qui range les modules du plus gros au plus petit, et ___BLANK3___ est la condition vraie pour un module plus gros que le budget BUDGET. Le rapport doit afficher une taille totale de 742 Ko, une liste allant de star-map (310 Ko) à navigation (24 Ko), et, case cochée, seulement les trois modules au-dessus de 100 Ko, les candidats à une séparation avec lazy().
- Clique dans l'ordre
Reconstitue la syntaxe du composant React Profiler pour mesurer les performances (l'attribut id avant onRender) :
- Tri vertical
Ordonne les étapes pour diagnostiquer les performances avec le React Profiler :
- Éditeur de code
La galerie affiche les images de trois planètes. Chaque image a une petite vignette floue, visible tout de suite, et une image complète qui n'apparaît qu'une fois chargée. Complète le composant PlanetImage : ___BLANK1___ est la valeur de l'attribut loading qui fait télécharger l'image complète seulement quand elle approche de l'écran, ___BLANK2___ est le statut défini après l'événement load (la vignette disparaît et l'image devient visible), et ___BLANK3___ est l'appel qui définit le statut 'error' après l'événement error. Chaque image a son propre état, et une erreur affiche un message avec le nom de la planète.
- Tri horizontal
Reconstitue la syntaxe d'un élément img avec l'attribut de chargement différé (lazy loading) :
- Éditeur de code
Le graphique TelemetryChart rend des centaines de barres, et le bouton en ajoute d'autres. Mesure ses rendus avec le composant Profiler : ___BLANK1___ est le troisième paramètre du callback, le temps de rendu en millisecondes (ce nom est déjà utilisé dans le corps de la fonction), ___BLANK2___ est le composant React qui mesure les rendus de son sous-arbre, et ___BLANK3___ est la fonction passée à son attribut onRender. Après le montage, une entrée avec la phase mount doit apparaître dans la console, et après un clic sur le bouton une entrée avec la phase update.
- Clique dans l'ordre
Reconstitue la déclaration d'un composant chargé en différé avec React.lazy :
- Tri vertical
Ordonne l'implémentation du lazy loading :
- Éditeur de code
Le calculateur de saut hyperspatial calcule la consommation de carburant dans un thread de travail, pour que l'interface ne se fige pas. Le thread a l'API d'un Web Worker : postMessage envoie des données, onmessage reçoit le résultat et terminate arrête le thread (dans l'aperçu, la fonction createWorker l'imite). Complète les trous : ___BLANK1___ est le champ de l'objet événement qui contient le résultat, ___BLANK2___ est la méthode que le nettoyage de l'effet utilise pour arrêter le thread, et ___BLANK3___ est la méthode que le clic utilise pour envoyer la distance au thread. Le thread doit être créé une seule fois après le montage, le bouton doit rester désactivé jusqu'à la réponse, et le résultat doit apparaître à l'écran.
- Clique dans l'ordre
Ordonne la configuration du Performance Observer :
- Éditeur de code
Les fonctions cacheFirst, networkFirst et pickStrategy iront dans le fichier sw.js et géreront l'événement fetch du Service Worker. Les fichiers statiques doivent utiliser la stratégie cache-first (d'abord la copie du cache, le réseau seulement sans copie), et les données de /api/ la stratégie network-first (d'abord une réponse fraîche du réseau, la copie du cache seulement sans connexion). Complète les trous : ___BLANK1___ est la méthode du cache qui cherche une réponse enregistrée, ___BLANK2___ est la méthode du cache qui enregistre une réponse, et ___BLANK3___ est la fonction de stratégie que pickStrategy renvoie pour les adresses /api/. Le panneau de l'aperçu permet de couper le réseau et de vérifier d'où viennent les réponses.
- Éditeur de code
Le formulaire de rapport pour le Centre de contrôle utilise useActionState, et le bouton d'envoi est un composant séparé, SubmitButton, qui doit savoir de lui-même que le formulaire est en cours d'envoi. Complète les trous : ___BLANK1___ est le champ renvoyé par le hook qui vaut true pendant l'envoi, ___BLANK2___ est le hook de react-dom qui lit l'état du formulaire dans lequel le composant est rendu, ___BLANK3___ est le composant bouton placé dans le formulaire, et ___BLANK4___ est le statut que l'action renvoie pour un rapport trop court. Un rapport de moins de 10 caractères doit afficher une erreur, un rapport plus long une confirmation, et pendant la transmission le bouton doit être désactivé et changer de libellé.