Cours JavaScript et React · Module 9: Hooks React modernes

use - hook universel pour gérer Promise et Context

19 min de lecture
Dans cette leçon6

La station orbitale attend le rapport d'une sonde. Jusqu'ici, dans React, cela voulait dire trois états (loading, error, data), un effet avec fetch et pas mal de conditions dans le JSX. En plus, les hooks classiques ne peuvent pas se trouver dans un if, donc tu lisais le contexte même quand tu n'en avais pas besoin.

use est une API de React qui te permet d'« utiliser » des ressources telles que des Promises ou des Context. Dans React 19, elle est stable (auparavant, elle n'existait que dans les versions expérimentales). C'est le premier hook qui peut être appelé de manière conditionnelle et dans des boucles. Il doit toutefois être appelé à l'intérieur d'un composant ou d'un hook, et selon react.dev, on ne doit pas le placer dans un bloc try/catch.

Les bases de use

1. Lire une Promise avec use

Quand tu passes une Promise à use, le composant « se suspend », et la frontière Suspense la plus proche affiche un fallback jusqu'à la résolution de la promesse :

1import { use, Suspense } from 'react';
2
3// Fonction renvoyant une Promise
4function fetchUser(id) {
5  return fetch(`/api/users/${id}`).then(res => res.json());
6}
7
8// Composant utilisant use avec une Promise
9function UserProfile({ userPromise }) {
10  // use « déballe » automatiquement la Promise
11  const user = use(userPromise);
12
13  return (
14    <div style={{
15      padding: '20px',
16      border: '1px solid #ddd',
17      borderRadius: '8px'
18    }}>
19      <h2>{user.name}</h2>
20      <p>Email : {user.email}</p>
21      <p>Inscrit le : {new Date(user.joinedDate).toLocaleDateString('fr-FR')}</p>
22    </div>
23  );
24}
25
26// Composant parent
27function UserDashboard({ userId }) {
28  // Attention : une nouvelle Promise est créée à CHAQUE rendu du parent (solution : un cache, plus bas)
29  const userPromise = fetchUser(userId);
30
31  return (
32    <div>
33      <h1>Tableau de bord utilisateur</h1>
34      <Suspense fallback={<div>Chargement des données utilisateur...</div>}>
35        <UserProfile userPromise={userPromise} />
36      </Suspense>
37    </div>
38  );
39}

UserProfile lit les données comme une valeur ordinaire, sans état de chargement. Le problème se trouve dans le parent : fetchUser(userId) crée une nouvelle Promise à chaque rendu de UserDashboard, donc chaque redessin envoie une nouvelle requête. React recommande des Promises venant d'un Server Component, d'un framework ou d'un cache. On construira un tel cache dans la section avancée.

2. Utiliser use avec Context

use lit aussi le contexte, comme useContext, mais il peut se trouver dans une condition. Voici trois composants qui utilisent deux contextes :

1import { use, createContext, useState } from 'react';
2
3// Création du Context
4const ThemeContext = createContext(null);
5const UserContext = createContext(null);
6
7// Composant utilisant use avec Context
8function Header() {
9  // use peut être utilisé de manière conditionnelle !
10  const theme = use(ThemeContext);
11  const user = use(UserContext);
12
13  return (
14    <header style={{
15      backgroundColor: theme === 'dark' ? '#333' : '#f8f9fa',
16      color: theme === 'dark' ? '#fff' : '#333',
17      padding: '20px'
18    }}>
19      <h1>Bienvenue, {user.name} !</h1>
20      <p>Thème actuel : {theme}</p>
21    </header>
22  );
23}
24
25// Possibilité d'utiliser de manière conditionnelle
26function ConditionalComponent({ showUserInfo }) {
27  const theme = use(ThemeContext);
28
29  // use peut être utilisé de manière conditionnelle - impossible avec les autres hooks !
30  if (showUserInfo) {
31    const user = use(UserContext);
32    return <div>Utilisateur : {user.name}</div>;
33  }
34
35  return <div>Thème : {theme}</div>;
36}
37
38// App with providers
39function App() {
40  const [theme, setTheme] = useState('light');
41  const [user] = useState({ name: 'Jean Dupont', id: 1 });
42
43  return (
44    <ThemeContext.Provider value={theme}>
45      <UserContext.Provider value={user}>
46        <div>
47          <Header />
48          <ConditionalComponent showUserInfo={true} />
49          <button onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}>
50            Changer de thème
51          </button>
52        </div>
53      </UserContext.Provider>
54    </ThemeContext.Provider>
55  );
56}

ConditionalComponent ne va chercher UserContext que lorsqu'il doit afficher l'utilisateur. On a écrit les fournisseurs sous la forme ThemeContext.Provider, ce qui fonctionne encore, mais depuis React 19 la forme courte <ThemeContext value={theme}> suffit, et Provider doit être déprécié à l'avenir.

3. use dans des boucles

Le hook peut aussi apparaître dans une boucle, ce qui est impensable avec useState. L'essentiel est que chaque Promise vienne d'un cache :

1import { use, Suspense } from 'react';
2
3// Fonction créant une Promise pour chaque article
4const postCache = new Map();
5
6function fetchPost(id) {
7  if (!postCache.has(id)) {
8    postCache.set(id, fetch(`/api/posts/${id}`).then(res => res.json()));
9  }
10  return postCache.get(id);
11}
12
13// Composant affichant plusieurs articles
14function PostList({ postIds }) {
15  const posts = [];
16  for (const id of postIds) {
17    // use peut être appelé dans une boucle !
18    posts.push(use(fetchPost(id)));
19  }
20
21  return (
22    <div>
23      {posts.map(post => (
24        <article
25          key={post.id}
26          style={{
27            marginBottom: '20px',
28            padding: '15px',
29            backgroundColor: '#f8f9fa',
30            borderRadius: '8px'
31          }}
32        >
33          <h3>{post.title}</h3>
34          <p>{post.content}</p>
35          <small>Auteur : {post.author}</small>
36        </article>
37      ))}
38    </div>
39  );
40}
41
42// Wrapper avec Suspense
43function BlogSection() {
44  const postIds = [1, 2, 3, 4, 5];
45
46  return (
47    <section>
48      <h2>Derniers articles</h2>
49      <Suspense fallback={<PostListSkeleton count={5} />}>
50        <PostList postIds={postIds} />
51      </Suspense>
52    </section>
53  );
54}
55
56function PostListSkeleton({ count }) {
57  return (
58    <div>
59      {Array.from({ length: count }).map((_, i) => (
60        <div
61          key={i}
62          style={{
63            marginBottom: '20px',
64            padding: '15px',
65            backgroundColor: '#e9ecef',
66            borderRadius: '8px',
67            height: '100px'
68          }}
69        />
70      ))}
71    </div>
72  );
73}

La boucle for...of se trouve directement dans le corps du composant. Si use était dans un callback passé à map, c'est une simple fonction qui l'appellerait et non le composant, et le linter de React signalerait une erreur. La fonction fetchPost garde les promesses dans postCache. Sans cela, chaque nouvelle tentative de rendu après la suspension créerait de nouvelles promesses et la liste ne se chargerait jamais.

Patterns avancés avec use

1. Cache pour les Promises

Généralisons cette idée. getCachedPromise reçoit une clé et une fabrique, et la Promise n'est créée qu'à la première demande pour une clé donnée :

1import { use, Suspense, useState } from 'react';
2
3// Cache simple pour les Promises
4const promiseCache = new Map();
5
6function getCachedPromise(key, promiseFactory) {
7  if (!promiseCache.has(key)) {
8    promiseCache.set(key, promiseFactory());
9  }
10  return promiseCache.get(key);
11}
12
13// Fonctions d'API
14function fetchUserData(userId) {
15  const cached = getCachedPromise(
16    `user-${userId}`,
17    () => fetch(`/api/users/${userId}`).then(res => res.json())
18  );
19  return cached;
20}
21
22function fetchUserPosts(userId) {
23  const cached = getCachedPromise(
24    `posts-${userId}`,
25    () => fetch(`/api/users/${userId}/posts`).then(res => res.json())
26  );
27  return cached;
28}
29
30// Composants
31function UserInfo({ userId }) {
32  const userPromise = fetchUserData(userId);
33  const user = use(userPromise);
34
35  return (
36    <div style={{
37      padding: '20px',
38      backgroundColor: '#f8f9fa',
39      borderRadius: '8px',
40      marginBottom: '20px'
41    }}>
42      <h2>{user.name}</h2>
43      <p>{user.email}</p>
44      <p>{user.location}</p>
45      <p>Inscrit le : {new Date(user.joinedDate).toLocaleDateString('fr-FR')}</p>
46    </div>
47  );
48}
49
50function UserPosts({ userId }) {
51  const postsPromise = fetchUserPosts(userId);
52  const posts = use(postsPromise);
53
54  return (
55    <div>
56      <h3>Articles de l'utilisateur ({posts.length})</h3>
57      {posts.map(post => (
58        <article
59          key={post.id}
60          style={{
61            marginBottom: '15px',
62            padding: '15px',
63            border: '1px solid #e9ecef',
64            borderRadius: '6px'
65          }}
66        >
67          <h4>{post.title}</h4>
68          <p>{post.excerpt}</p>
69          <small>{new Date(post.date).toLocaleDateString('fr-FR')}</small>
70        </article>
71      ))}
72    </div>
73  );
74}
75
76function UserProfilePage() {
77  const [userId, setUserId] = useState(1);
78
79  return (
80    <div style={{ maxWidth: '800px', margin: '0 auto', padding: '20px' }}>
81      <div style={{ marginBottom: '20px' }}>
82        <button onClick={() => setUserId(1)} disabled={userId === 1}>
83          Utilisateur 1
84        </button>
85        <button onClick={() => setUserId(2)} disabled={userId === 2}>
86          Utilisateur 2
87        </button>
88        <button onClick={() => promiseCache.clear()}>
89          Vider le cache
90        </button>
91      </div>
92
93      <Suspense fallback={<div>Chargement du profil...</div>}>
94        <UserInfo userId={userId} />
95      </Suspense>
96
97      <Suspense fallback={<div>Chargement des articles...</div>}>
98        <UserPosts userId={userId} />
99      </Suspense>
100    </div>
101  );
102}

Deux frontières Suspense séparées permettent au profil et aux posts d'apparaître indépendamment. Le bouton « Vider le cache » supprime les promesses mais ne force pas de nouveau rendu, donc les nouvelles données seront chargées au prochain changement d'utilisateur.

2. Chargement de données en parallèle

Promise.all récupère plusieurs ressources en même temps, et use les attend toutes ensemble. D'abord la fonction d'API et le composant du tableau de bord :

1import { use, Suspense } from 'react';
2
3// Fonctions d'API
4async function fetchDashboardData() {
5  const [stats, activities, notifications] = await Promise.all([
6    fetch('/api/stats').then(res => res.json()),
7    fetch('/api/activities').then(res => res.json()),
8    fetch('/api/notifications').then(res => res.json())
9  ]);
10
11  return { stats, activities, notifications };
12}
13
14// Composant tableau de bord
15function Dashboard({ dataPromise }) {
16  const { stats, activities, notifications } = use(dataPromise);
17
18  return (
19    <div style={{
20      display: 'grid',
21      gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
22      gap: '20px',
23      padding: '20px'
24    }}>
25      <StatsCard stats={stats} />
26      <ActivitiesCard activities={activities} />
27      <NotificationsCard notifications={notifications} />
28    </div>
29  );
30}
31

Les données arrivent aux trois cartes par les props, donc les cartes sont des composants ordinaires. Voici leur implémentation :

1function StatsCard({ stats }) {
2  return (
3    <div style={{
4      padding: '20px',
5      backgroundColor: '#f8f9fa',
6      borderRadius: '8px',
7      boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
8    }}>
9      <h3>Statistiques</h3>
10      <div style={{ display: 'grid', gap: '10px' }}>
11        <div>
12          <strong>Utilisateurs :</strong> {stats.users.toLocaleString('fr-FR')}
13        </div>
14        <div>
15          <strong>Revenus :</strong> {stats.revenue.toLocaleString('fr-FR', {
16            style: 'currency',
17            currency: 'EUR'
18          })}
19        </div>
20        <div>
21          <strong>Croissance :</strong>
22          <span style={{ color: stats.growth > 0 ? '#28a745' : '#dc3545' }}>
23            {stats.growth > 0 ? '+' : ''}{stats.growth}%
24          </span>
25        </div>
26      </div>
27    </div>
28  );
29}
30
31function ActivitiesCard({ activities }) {
32  return (
33    <div style={{
34      padding: '20px',
35      backgroundColor: '#f8f9fa',
36      borderRadius: '8px',
37      boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
38    }}>
39      <h3>Activités récentes</h3>
40      <ul style={{ listStyle: 'none', padding: 0 }}>
41        {activities.slice(0, 5).map(activity => (
42          <li
43            key={activity.id}
44            style={{
45              padding: '8px 0',
46              borderBottom: '1px solid #e9ecef'
47            }}
48          >
49            <div style={{ fontWeight: '500' }}>{activity.user}</div>
50            <div style={{ fontSize: '14px', color: '#6c757d' }}>
51              {activity.action}
52            </div>
53            <small style={{ color: '#6c757d' }}>
54              {new Date(activity.timestamp).toLocaleString('fr-FR')}
55            </small>
56          </li>
57        ))}
58      </ul>
59    </div>
60  );
61}
62
63function NotificationsCard({ notifications }) {
64  const unreadCount = notifications.filter(n => !n.read).length;
65
66  return (
67    <div style={{
68      padding: '20px',
69      backgroundColor: '#f8f9fa',
70      borderRadius: '8px',
71      boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
72    }}>
73      <h3>
74        Notifications
75        {unreadCount > 0 && (
76          <span style={{
77            marginLeft: '10px',
78            padding: '2px 8px',
79            backgroundColor: '#dc3545',
80            color: 'white',
81            borderRadius: '12px',
82            fontSize: '14px'
83          }}>
84            {unreadCount}
85          </span>
86        )}
87      </h3>
88      <div style={{ maxHeight: '200px', overflowY: 'auto' }}>
89        {notifications.map(notification => (
90          <div
91            key={notification.id}
92            style={{
93              padding: '10px',
94              marginBottom: '8px',
95              backgroundColor: notification.read ? 'transparent' : '#e3f2fd',
96              borderRadius: '4px',
97              fontSize: '14px'
98            }}
99          >
100            <strong>{notification.title}</strong>
101            <p style={{ margin: '4px 0' }}>{notification.message}</p>
102          </div>
103        ))}
104      </div>
105    </div>
106  );
107}
108

Aucune carte ne sait rien des Promises ni de Suspense. Enfin, le composant principal et le squelette :

1// Composant principal
2function DashboardApp() {
3  const dataPromise = fetchDashboardData();
4
5  return (
6    <div>
7      <h1 style={{ padding: '20px' }}>Panneau d'administration</h1>
8      <Suspense fallback={<DashboardSkeleton />}>
9        <Dashboard dataPromise={dataPromise} />
10      </Suspense>
11    </div>
12  );
13}
14
15function DashboardSkeleton() {
16  return (
17    <div style={{
18      display: 'grid',
19      gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
20      gap: '20px',
21      padding: '20px'
22    }}>
23      {[1, 2, 3].map(i => (
24        <div
25          key={i}
26          style={{
27            height: '250px',
28            backgroundColor: '#e9ecef',
29            borderRadius: '8px',
30            animation: 'pulse 1.5s ease-in-out infinite'
31          }}
32        />
33      ))}
34    </div>
35  );
36}

Ici encore, la Promise est créée dans le rendu de DashboardApp, donc dans une vraie application, déplace-la dans un cache ou dans un Server Component.

3. Error boundaries avec use

Une Promise rejetée n'aboutit pas dans un try/catch, mais dans l'Error Boundary la plus proche. D'abord la frontière elle-même, qui exige toujours un composant de classe :

1import { use, Suspense, Component, useState } from 'react';
2
3// Error Boundary
4class ErrorBoundary extends Component {
5  constructor(props) {
6    super(props);
7    this.state = { hasError: false, error: null };
8  }
9
10  static getDerivedStateFromError(error) {
11    return { hasError: true, error };
12  }
13
14  componentDidCatch(error, errorInfo) {
15    console.error('Erreur capturée par la boundary :', error, errorInfo);
16  }
17
18  render() {
19    if (this.state.hasError) {
20      return (
21        <div style={{
22          padding: '20px',
23          backgroundColor: '#f8d7da',
24          border: '1px solid #f5c6cb',
25          borderRadius: '8px',
26          color: '#721c24'
27        }}>
28          <h3>Une erreur s'est produite</h3>
29          <p>{this.state.error?.message || 'Erreur inconnue'}</p>
30          <button
31            onClick={() => this.setState({ hasError: false, error: null })}
32            style={{
33              marginTop: '10px',
34              padding: '8px 16px',
35              backgroundColor: '#721c24',
36              color: 'white',
37              border: 'none',
38              borderRadius: '4px',
39              cursor: 'pointer'
40            }}
41          >
42            Réessayer
43          </button>
44        </div>
45      );
46    }
47
48    return this.props.children;
49  }
50}
51

getDerivedStateFromError bascule la vue sur un message, et componentDidCatch journalise l'erreur. Voici une démo capable de simuler une panne :

1// Fonction d'API qui peut renvoyer une erreur
2function fetchDataWithError(shouldFail = false) {
3  return new Promise((resolve, reject) => {
4    setTimeout(() => {
5      if (shouldFail) {
6        reject(new Error('Erreur lors de la récupération des données depuis le serveur'));
7      } else {
8        resolve({
9          message: 'Données chargées avec succès !',
10          timestamp: new Date().toISOString()
11        });
12      }
13    }, 1000);
14  });
15}
16
17// Composant utilisant use
18function DataDisplay({ dataPromise }) {
19  const data = use(dataPromise);
20
21  return (
22    <div style={{
23      padding: '20px',
24      backgroundColor: '#d4edda',
25      border: '1px solid #c3e6cb',
26      borderRadius: '8px',
27      color: '#155724'
28    }}>
29      <h3>Succès !</h3>
30      <p>{data.message}</p>
31      <small>Chargé le : {new Date(data.timestamp).toLocaleString('fr-FR')}</small>
32    </div>
33  );
34}
35
36// Composant principal
37function ErrorHandlingDemo() {
38  const [shouldFail, setShouldFail] = useState(false);
39  const [key, setKey] = useState(0);
40
41  const dataPromise = fetchDataWithError(shouldFail);
42
43  return (
44    <div style={{ maxWidth: '600px', margin: '0 auto', padding: '20px' }}>
45      <h2>Gestion des erreurs avec use</h2>
46
47      <div style={{ marginBottom: '20px' }}>
48        <label>
49          <input
50            type="checkbox"
51            checked={shouldFail}
52            onChange={(e) => setShouldFail(e.target.checked)}
53          />
54          Simuler une erreur
55        </label>
56        <button
57          onClick={() => setKey(k => k + 1)}
58          style={{ marginLeft: '20px' }}
59        >
60          Rafraîchir
61        </button>
62      </div>
63
64      <ErrorBoundary key={key}>
65        <Suspense fallback={
66          <div style={{
67            padding: '20px',
68            backgroundColor: '#cfe2ff',
69            border: '1px solid #b6d4fe',
70            borderRadius: '8px',
71            color: '#084298'
72          }}>
73            Chargement des données...
74          </div>
75        }>
76          <DataDisplay dataPromise={dataPromise} />
77        </Suspense>
78      </ErrorBoundary>
79    </div>
80  );
81}

Changer la key de ErrorBoundary recrée la frontière, ce qui réinitialise l'erreur. Au lieu d'une frontière, tu peux aussi ajouter .catch() à la promesse et renvoyer une valeur de remplacement.

Applications pratiques de use

1. Système de routage avec lazy loading

Un routeur simple combine lazy, Suspense et un contexte lu avec use :

1import { use, Suspense, lazy, useState, createContext } from 'react';
2
3// Simulation d'un système de routage
4const routes = {
5  '/': lazy(() => import('./pages/Home')),
6  '/about': lazy(() => import('./pages/About')),
7  '/contact': lazy(() => import('./pages/Contact')),
8  '/profile': lazy(() => import('./pages/Profile'))
9};
10
11// Context pour le routage
12const RouteContext = createContext(null);
13
14// Hook de navigation
15function useNavigation() {
16  const { navigate } = use(RouteContext);
17  return navigate;
18}
19
20// Composant de page
21function Page({ path }) {
22  const Component = routes[path] || routes['/'];
23
24  return (
25    <Suspense fallback={<PageLoader />}>
26      <Component />
27    </Suspense>
28  );
29}
30
31function PageLoader() {
32  return (
33    <div style={{
34      display: 'flex',
35      justifyContent: 'center',
36      alignItems: 'center',
37      minHeight: '400px'
38    }}>
39      <div style={{ textAlign: 'center' }}>
40        <div style={{
41          width: '50px',
42          height: '50px',
43          border: '3px solid #f3f3f3',
44          borderTop: '3px solid #007bff',
45          borderRadius: '50%',
46          animation: 'spin 1s linear infinite',
47          margin: '0 auto 20px'
48        }} />
49        <p>Chargement de la page...</p>
50      </div>
51    </div>
52  );
53}
54
55// Application principale
56function RouterApp() {
57  const [currentPath, setCurrentPath] = useState('/');
58
59  const navigate = (path) => {
60    setCurrentPath(path);
61    window.history.pushState({}, '', path);
62  };
63
64  return (
65    <RouteContext.Provider value={{ navigate, currentPath }}>
66      <div style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}>
67        <Navigation />
68        <main style={{ flex: 1, padding: '20px' }}>
69          <Page path={currentPath} />
70        </main>
71      </div>
72    </RouteContext.Provider>
73  );
74}
75
76function Navigation() {
77  const navigate = useNavigation();
78  const { currentPath } = use(RouteContext);
79
80  const links = [
81    { path: '/', label: 'Accueil' },
82    { path: '/about', label: 'À propos' },
83    { path: '/contact', label: 'Contact' },
84    { path: '/profile', label: 'Profil' }
85  ];
86
87  return (
88    <nav style={{
89      backgroundColor: '#333',
90      padding: '10px 20px'
91    }}>
92      {links.map(link => (
93        <button
94          key={link.path}
95          onClick={() => navigate(link.path)}
96          style={{
97            marginRight: '10px',
98            padding: '8px 16px',
99            backgroundColor: currentPath === link.path ? '#007bff' : 'transparent',
100            color: 'white',
101            border: 'none',
102            borderRadius: '4px',
103            cursor: 'pointer'
104          }}
105        >
106          {link.label}
107        </button>
108      ))}
109    </nav>
110  );
111}

lazy ne charge le code d'une page qu'à la première visite, et Suspense affiche PageLoader en attendant. Ce n'est qu'un modèle, car en pratique tu utiliseras React Router ou le routeur de ton framework.

2. Formulaires dynamiques avec validation

Le formulaire garde son état dans un contexte, et les champs le lisent via use. D'abord le contexte, le hook de champ et le composant FormField :

1import { use, createContext, useState } from 'react';
2
3// Context pour le formulaire
4const FormContext = createContext(null);
5
6// Hook pour la gestion du formulaire
7function useFormField(name) {
8  const form = use(FormContext);
9
10  return {
11    value: form.values[name] || '',
12    error: form.errors[name],
13    onChange: (e) => form.setValue(name, e.target.value),
14    onBlur: () => form.validateField(name)
15  };
16}
17
18// Composant de champ de formulaire
19function FormField({ name, label, type = 'text', validate }) {
20  const field = useFormField(name);
21  const form = use(FormContext);
22
23  // Enregistre le validateur
24  if (validate && !form.validators[name]) {
25    form.registerValidator(name, validate);
26  }
27
28  return (
29    <div style={{ marginBottom: '15px' }}>
30      <label style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}>
31        {label}
32      </label>
33      <input
34        type={type}
35        value={field.value}
36        onChange={field.onChange}
37        onBlur={field.onBlur}
38        style={{
39          width: '100%',
40          padding: '8px',
41          border: `1px solid ${field.error ? '#dc3545' : '#ddd'}`,
42          borderRadius: '4px'
43        }}
44      />
45      {field.error && (
46        <div style={{ color: '#dc3545', fontSize: '14px', marginTop: '5px' }}>
47          {field.error}
48        </div>
49      )}
50    </div>
51  );
52}
53

Chaque champ enregistre son validateur et affiche sa propre erreur. Voici le fournisseur qui garde les valeurs et lance la validation :

1// Provider du formulaire
2function FormProvider({ children, onSubmit }) {
3  const [values, setValues] = useState({});
4  const [errors, setErrors] = useState({});
5  const [validators] = useState({});
6
7  const setValue = (name, value) => {
8    setValues(prev => ({ ...prev, [name]: value }));
9    // Efface l'erreur lors du changement de valeur
10    setErrors(prev => ({ ...prev, [name]: '' }));
11  };
12
13  const registerValidator = (name, validate) => {
14    validators[name] = validate;
15  };
16
17  const validateField = (name) => {
18    if (validators[name]) {
19      const error = validators[name](values[name], values);
20      setErrors(prev => ({ ...prev, [name]: error }));
21      return !error;
22    }
23    return true;
24  };
25
26  const validateAll = () => {
27    const newErrors = {};
28    let isValid = true;
29
30    Object.keys(validators).forEach(name => {
31      const error = validators[name](values[name], values);
32      if (error) {
33        newErrors[name] = error;
34        isValid = false;
35      }
36    });
37
38    setErrors(newErrors);
39    return isValid;
40  };
41
42  const handleSubmit = (e) => {
43    e.preventDefault();
44    if (validateAll()) {
45      onSubmit(values);
46    }
47  };
48
49  const formValue = {
50    values,
51    errors,
52    validators,
53    setValue,
54    registerValidator,
55    validateField,
56    handleSubmit
57  };
58
59  return (
60    <FormContext.Provider value={formValue}>
61      <form onSubmit={handleSubmit}>
62        {children}
63      </form>
64    </FormContext.Provider>
65  );
66}
67

Les validateurs reçoivent un second argument : toutes les valeurs du formulaire. C'est important dans l'exemple d'utilisation :

1// Exemple d'utilisation
2function RegistrationFormExample() {
3  const handleSubmit = (values) => {
4    console.log('Formulaire soumis :', values);
5    alert('Inscription effectuée avec succès !');
6  };
7
8  return (
9    <div style={{ maxWidth: '400px', margin: '0 auto', padding: '20px' }}>
10      <h2>Formulaire d'inscription</h2>
11
12      <FormProvider onSubmit={handleSubmit}>
13        <FormField
14          name="email"
15          label="Email"
16          type="email"
17          validate={(value) => {
18            if (!value) return 'L’email est requis';
19            if (!/\S+@\S+\.\S+/.test(value)) return 'Email invalide';
20            return '';
21          }}
22        />
23
24        <FormField
25          name="password"
26          label="Mot de passe"
27          type="password"
28          validate={(value) => {
29            if (!value) return 'Le mot de passe est requis';
30            if (value.length < 8) return 'Le mot de passe doit comporter au moins 8 caractères';
31            return '';
32          }}
33        />
34
35        <FormField
36          name="confirmPassword"
37          label="Confirme le mot de passe"
38          type="password"
39          validate={(value, allValues) => {
40            if (value !== allValues.password) return 'Les mots de passe ne correspondent pas';
41            return '';
42          }}
43        />
44
45        <button
46          type="submit"
47          style={{
48            width: '100%',
49            padding: '10px',
50            backgroundColor: '#007bff',
51            color: 'white',
52            border: 'none',
53            borderRadius: '4px',
54            fontSize: '16px',
55            cursor: 'pointer'
56          }}
57        >
58          S'inscrire
59        </button>
60      </FormProvider>
61    </div>
62  );
63}

Le champ « Confirme le mot de passe » compare sa valeur avec allValues.password. Il ne peut pas appeler use dans le validateur, car use ne fonctionne que pendant le rendu d'un composant ou d'un hook, et le validateur s'exécute dans un gestionnaire d'événement.

Bonnes pratiques avec use

1. Quand utiliser use

Voici les bons et les mauvais usages en bref :

1// BON - utilisation conditionnelle
2function ConditionalData({ showDetails }) {
3  if (showDetails) {
4    const details = use(detailsPromise);
5    return <DetailView details={details} />;
6  }
7  return <Summary />;
8}
9
10// BON - dans des boucles
11function MultipleResources({ resourceIds }) {
12  const resources = [];
13  for (const id of resourceIds) {
14    resources.push(use(fetchResource(id)));
15  }
16  return <ResourceList resources={resources} />;
17}
18
19// MAUVAIS - use inutile pour des valeurs simples
20function BadExample() {
21  const value = use(Promise.resolve(42)); // Inutile !
22  return <div>{value}</div>;
23}

Dans MultipleResources, la fonction fetchResource doit renvoyer une Promise issue d'un cache, comme fetchPost plus haut.

2. Cache et optimisation

Le cache peut être enfermé dans un hook personnalisé :

1// Implémentation simple d'un cache
2const cache = new Map();
3
4function useCachedResource(key, fetcher) {
5  if (!cache.has(key)) {
6    cache.set(key, fetcher());
7  }
8
9  const promise = cache.get(key);
10  const resource = use(promise);
11
12  return resource;
13}
14
15// Utilisation
16function CachedDataComponent({ userId }) {
17  const userData = useCachedResource(
18    `user-${userId}`,
19    () => fetchUser(userId)
20  );
21
22  return <UserProfile user={userData} />;
23}

useCachedResource est un hook personnalisé ordinaire, et une Map au niveau du module survit entre les rendus.

3. Gestion des erreurs

Les nouvelles tentatives avec un délai exponentiel peuvent être enfermées dans une fabrique de promesses :

1// Wrapper avec logique de réessai
2function createRetryablePromise(fetcher, maxRetries = 3) {
3  return async () => {
4    let lastError;
5
6    for (let i = 0; i < maxRetries; i++) {
7      try {
8        return await fetcher();
9      } catch (error) {
10        lastError = error;
11        if (i < maxRetries - 1) {
12          // Backoff exponentiel
13          await new Promise(resolve =>
14            setTimeout(resolve, Math.pow(2, i) * 1000)
15          );
16        }
17      }
18    }
19
20    throw lastError;
21  };
22}
23
24// Utilisation
25function RobustDataFetching() {
26  const dataPromise = createRetryablePromise(
27    () => fetch('/api/critical-data').then(res => res.json())
28  )();
29
30  return (
31    <ErrorBoundary>
32      <Suspense fallback={<div>Chargement...</div>}>
33        <DataComponent dataPromise={dataPromise} />
34      </Suspense>
35    </ErrorBoundary>
36  );
37}

Ici aussi, la Promise est créée pendant le rendu, donc en production, combine-la avec un cache.

Rappel avant les exercices : memo avec une comparaison personnalisée et le processus d'optimisation

Les exercices après cette leçon reviennent sur memo. Par défaut, memo compare chaque prop de façon superficielle, avec Object.is. Comme second argument, tu peux passer ta propre fonction arePropsEqual(prevProps, nextProps) : quand elle renvoie true, React considère les props comme égales et saute le rendu, et quand elle renvoie false, il rend le composant :

1import { memo } from 'react';
2
3const TelemetryPanel = memo(function TelemetryPanel({ readings, title }) {
4  return <Chart data={readings} label={title} />;
5}, (prevProps, nextProps) =>
6  // true = on considère les props comme égales, donc React saute le rendu
7  prevProps.readings.length === nextProps.readings.length && prevProps.title === nextProps.title
8);

La fonction ne fait rien d'elle-même, par exemple elle n'active pas une comparaison profonde des objets : seul compte ce que tu y écris. Une erreur dans la comparaison signifie un écran périmé, donc utilise-la rarement.

Chacune de ces techniques est un outil, pas un but. Optimise toujours dans le même ordre : d'abord une mesure de référence (baseline), puis le profilage, qui montre où part le temps de rendu, ensuite l'identification du goulot d'étranglement et la mise en œuvre d'une seule correction. À la fin, tu mesures l'impact du changement et tu lances les tests de non-régression pour vérifier que l'application fonctionne toujours comme avant. Ces deux dernières étapes peuvent se faire dans n'importe quel ordre, à condition de n'en sauter aucune.

Résumé

Le hook use ouvre de nouvelles possibilités dans React, permettant une gestion plus flexible des données asynchrones et du contexte. Sa capacité unique à être utilisé de manière conditionnelle et dans des boucles en fait un outil puissant pour les applications avancées.

Mon conseil : ne crée jamais de Promise dans un composant client sans cache. Retiens : use est le récepteur de la station qui attend le rapport de la sonde, mais le rapport lui-même a besoin d'un canal fixe.

Code de cette leçon : App.jsx
1import React, { use, createContext, useState, Suspense } from 'react';
2
3// Contextes de l'application spatiale
4const ThemeContext = createContext('dark');
5const CrewContext = createContext(null);
6
7// Simulating data fetching from API (Promise)
8function fetchMissionData() {
9  return new Promise((resolve) => {
10    setTimeout(() => {
11      resolve({
12        name: 'Andromeda Mission',
13        status: 'In progress',
14        crew: 7,
15        distance: "2,537 millions d'al",
16        fuelLevel: 78,
17      });
18    }, 1500);
19  });
20}
21
22// Component reading data from Context using use()
23function CrewPanel() {
24  // use() with Context - works like useContext, but can be conditional!
25  const crew = use(CrewContext);
26  const theme = use(ThemeContext);
27
28  const isDark = theme === 'dark';
29  return (
30    <div style={{
31      padding: '16px', marginBottom: '16px',
32      background: isDark ? 'rgba(0, 212, 255, 0.08)' : '#f0f8ff',
33      borderRadius: '10px', border: `1px solid ${isDark ? '#1a3a5c' : '#b0d4f1'}`,
34    }}>
35      <h3 style={{ color: isDark ? '#00d4ff' : '#0066cc', margin: '0 0 8px' }}>
36        Équipage du vaisseau
37      </h3>
38      {crew.map((member) => (
39        <div key={member.id} style={{
40          display: 'flex', justifyContent: 'space-between',
41          padding: '6px 0', borderBottom: `1px solid ${isDark ? '#1a2a3c' : '#dde8f0'}`,
42          color: isDark ? '#c0c8d0' : '#333', fontSize: '14px',
43        }}>
44          <span>{member.name}</span>
45          <span style={{ color: isDark ? '#778da9' : '#666' }}>{member.role}</span>
46        </div>
47      ))}
48    </div>
49  );
50}
51
52// Composant qui lit une Promise avec use()
53function MissionDetails({ missionPromise }) {
54  // use() « déballe » automatiquement la Promise
55  const mission = use(missionPromise);
56
57  return (
58    <div style={{
59      padding: '20px', background: 'rgba(255, 215, 0, 0.08)',
60      borderRadius: '10px', border: '1px solid #3a3a1c',
61    }}>
62      <h3 style={{ color: '#ffd700', margin: '0 0 12px' }}>
63        {mission.name}
64      </h3>
65      <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' }}>
66        {[
67          ['Status', mission.status],
68          ['Équipage', `${mission.crew} personnes`],
69          ['Distance', mission.distance],
70          ['Carburant', `${mission.fuelLevel}%`],
71        ].map(([label, value]) => (
72          <div key={label} style={{
73            padding: '10px', background: 'rgba(255, 255, 255, 0.03)',
74            borderRadius: '6px', fontSize: '13px',
75          }}>
76            <div style={{ color: '#778da9', marginBottom: '4px' }}>{label}</div>
77            <div style={{ color: '#e0e1dd', fontWeight: 'bold' }}>{value}</div>
78          </div>
79        ))}
80      </div>
81    </div>
82  );
83}
84
85// use() peut se trouver dans une condition : ici, on lit le contexte seulement quand les détails sont visibles.
86// Avec useContext, un tel appel après une instruction return serait interdit.
87function ConditionalInfo({ showDetails }) {
88  if (!showDetails) {
89    return (
90      <div style={{
91        padding: '12px', marginTop: '16px',
92        background: 'rgba(255, 255, 255, 0.03)',
93        borderRadius: '8px', color: '#778da9', fontSize: '13px',
94      }}>
95        Détails masqués
96      </div>
97    );
98  }
99  const crew = use(CrewContext);
100  return (
101    <div style={{
102      padding: '12px', marginTop: '16px',
103      background: 'rgba(0, 212, 255, 0.06)',
104      borderRadius: '8px', color: '#c0c8d0', fontSize: '13px',
105    }}>
106      À bord : {crew.length} membres d’équipage ({crew.map((member) => member.role).join(', ')})
107    </div>
108  );
109}
110
111function LoadingFallback() {
112  return (
113    <div style={{
114      padding: '40px', textAlign: 'center', color: '#778da9',
115    }}>
116      <div style={{ fontSize: '24px', marginBottom: '10px' }}>Chargement des données de la mission...</div>
117      <div style={{
118        width: '50px', height: '50px', margin: '0 auto',
119        border: '3px solid #1a3a5c', borderTop: '3px solid #00d4ff',
120        borderRadius: '50%', animation: 'spin 1s linear infinite',
121      }} />
122    </div>
123  );
124}
125
126export default function App() {
127  const [showDetails, setShowDetails] = useState(true);
128  const missionPromise = React.useMemo(() => fetchMissionData(), []);
129
130  const crewData = [
131    { id: 1, name: 'Capitaine Nova', role: 'Commandante' },
132    { id: 2, name: 'Dr. Stella', role: 'Navigatrice' },
133    { id: 3, name: 'Ing. Orion', role: 'Mécanicien' },
134    { id: 4, name: 'Lt. Vega', role: 'Pilote' },
135  ];
136
137  return (
138    <ThemeContext.Provider value="dark">
139      <CrewContext.Provider value={crewData}>
140        <div style={{
141          minHeight: '100vh',
142          background: 'linear-gradient(135deg, #0a0e27 0%, #1a1a3e 100%)',
143          padding: '24px', fontFamily: 'monospace',
144        }}>
145          <h1 style={{ color: '#00d4ff', textAlign: 'center', marginBottom: '8px' }}>
146            Hook use() - Promise et Context
147          </h1>
148          <p style={{ color: '#778da9', textAlign: 'center', marginBottom: '24px', fontSize: '14px' }}>
149            Une nouvelle façon de lire les données en React
150          </p>
151
152          <div style={{ maxWidth: '600px', margin: '0 auto' }}>
153            <CrewPanel />
154
155            <Suspense fallback={<LoadingFallback />}>
156              <MissionDetails missionPromise={missionPromise} />
157            </Suspense>
158
159            <div style={{ marginTop: '16px', textAlign: 'center' }}>
160              <button
161                onClick={() => setShowDetails(d => !d)}
162                style={{
163                  padding: '10px 20px', background: '#1a3a5c',
164                  border: 'none', borderRadius: '6px', color: '#00d4ff',
165                  cursor: 'pointer', fontSize: '13px',
166                }}
167              >
168                {showDetails ? 'Masquer les détails' : 'Afficher les détails'}
169              </button>
170            </div>
171
172            <ConditionalInfo showDetails={showDetails} />
173          </div>
174        </div>
175      </CrewContext.Provider>
176    </ThemeContext.Provider>
177  );
178}

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 deuxième argument (comparateur personnalisé) dans React.memo(Component, compareFn) ?

Tâches pratiques dans le jeu

  • Tri vertical

    Ordonne le flux de travail d'optimisation des performances :

Articles utiles