Cours JavaScript et React · Module 9: Hooks React modernes

useOptimistic - mises à jour optimistes de l'UI

20 min de lecture
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  }
44

La 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  };
45

React 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  }
63

useRef 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  };
97

Chaque 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. 1. À quoi sert le hook useTransition dans React ?

  2. 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é.

Articles utiles