Cours JavaScript et React · Module 9: Hooks React modernes

Projet : Application React 18+ avancée

14 min de lecture
Dans cette leçon4

Chaque équipage passe un jour par une simulation de mission complète, où tous les systèmes fonctionnent en même temps. Tu connais déjà les transitions, les valeurs différées, useId, use et useOptimistic, mais séparément. Maintenant, ils doivent coopérer dans un seul code.

Dans ce projet, tu vas créer une application React moderne en utilisant toutes les fonctionnalités de React 18+ que tu as apprises. Tu vas construire un Advanced Task Management System - un système avancé de gestion de tâches utilisant les Concurrent Features, les Server Components et les hooks modernes.

Spécification du projet

Fonctionnalités principales

  1. Tableau de bord avec mises à jour en temps réel

    • Utilisation de useTransition et useDeferredValue
    • Mises à jour optimistes avec useOptimistic
    • Streaming de contenu avec Suspense
  2. Formulaires avancés

    • useFormStatus et useActionState
    • Server Actions
    • Validation en temps réel
  3. Système de notifications

    • useId pour l'accessibilité
    • Rendu concurrent
    • Amélioration progressive
  4. Performance et optimisation

    • React Compiler (facultatif)
    • Automatic Batching
    • Selective Hydration

React Compiler dispose d'une version stable 1.0 depuis 2025 et ajoute lui-même la mémoïsation, donc il vaut la peine d'y réfléchir.

Structure de l'application

Le projet utilise l'App Router de Next.js, un framework qui prend en charge les Server Components et les Server Actions. Voici la carte des dossiers :

1src/
2├── app/
3│   ├── layout.tsx          # Layout racine (Server Component)
4│   ├── page.tsx            # Page d'accueil
5│   ├── dashboard/
6│   │   ├── page.tsx        # Tableau de bord (Server Component)
7│   │   └── loading.tsx     # UI de chargement
8│   ├── tasks/
9│   │   ├── page.tsx        # Liste des tâches
10│   │   ├── [id]/page.tsx   # Détails d'une tâche
11│   │   └── new/page.tsx    # Créer une tâche
12│   └── api/
13│       ├── tasks/route.ts  # API des tâches
14│       └── notifications/route.ts
15├── components/
16│   ├── ui/                 # Composants UI de base
17│   ├── forms/              # Composants de formulaire
18│   ├── dashboard/          # Composants du tableau de bord
19│   └── tasks/              # Composants de tâches
20├── hooks/                  # Hooks personnalisés
21├── lib/                    # Utilitaires
22└── types/                  # Types TypeScript

Le dossier app/ contient les routes, components/ les briques de l'UI, et lib/ la logique serveur.

Implémentation étape par étape

Étape 1 : Structure de base et types

On commence par les données. Une interface TypeScript est un contrat qui décrit les champs d'une tâche, d'un utilisateur et d'une notification :

1// types/index.ts
2export interface Task {
3  id: string;
4  title: string;
5  description: string;
6  status: 'pending' | 'in_progress' | 'completed';
7  priority: 'low' | 'medium' | 'high';
8  assignee?: string;
9  dueDate?: Date;
10  createdAt: Date;
11  updatedAt: Date;
12}
13
14export interface User {
15  id: string;
16  name: string;
17  email: string;
18  avatar?: string;
19}
20
21export interface Notification {
22  id: string;
23  type: 'task_created' | 'task_updated' | 'task_completed';
24  message: string;
25  timestamp: Date;
26  read: boolean;
27}

Les champs avec un point d'interrogation, comme assignee?, sont facultatifs, et les unions comme 'low' | 'medium' | 'high' n'autorisent que les valeurs listées.

Étape 2 : Layout racine avec Server Components

Le layout est un Server Component asynchrone, donc il peut récupérer les données de l'utilisateur dès le serveur :

1// app/layout.tsx (Server Component)
2import { Suspense } from 'react';
3import { Navigation } from '@/components/Navigation';
4import { NotificationProvider } from '@/components/NotificationProvider';
5
6async function RootLayout({ children }: { children: React.ReactNode }) {
7  // Récupère les données utilisateur sur le serveur
8  const user = await getCurrentUser();
9  const notifications = await getUnreadNotifications(user.id);
10
11  return (
12    <html lang="fr">
13      <head>
14        <title>Advanced Task Manager</title>
15        <meta name="description" content="Système avancé de gestion de tâches" />
16      </head>
17      <body>
18        <NotificationProvider initialNotifications={notifications}>
19          <div className="app-layout">
20            <Suspense fallback={<NavigationSkeleton />}>
21              <Navigation user={user} />
22            </Suspense>
23
24            <main className="main-content">
25              <Suspense fallback={<PageSkeleton />}>
26                {children}
27              </Suspense>
28            </main>
29          </div>
30        </NotificationProvider>
31      </body>
32    </html>
33  );
34}
35
36export default RootLayout;

Deux frontières Suspense permettent à la navigation et au contenu d'apparaître indépendamment. Dans Next.js, le titre et la description de la page se définissent en général via un export metadata, plutôt qu'avec un <head> écrit à la main.

Étape 3 : Tableau de bord avec Concurrent Features

La page du tableau de bord récupère en parallèle les statistiques et les tâches récentes, et diffuse en streaming les sections plus lentes :

1// app/dashboard/page.tsx (Server Component)
2import { Suspense } from 'react';
3import { TasksOverview } from '@/components/dashboard/TasksOverview';
4import { RecentActivity } from '@/components/dashboard/RecentActivity';
5import { PerformanceMetrics } from '@/components/dashboard/PerformanceMetrics';
6
7async function DashboardPage() {
8  // Récupération de données en parallèle
9  const [tasksStats, recentTasks] = await Promise.all([
10    getTasksStatistics(),
11    getRecentTasks(10)
12  ]);
13
14  return (
15    <div className="dashboard">
16      <h1>Tableau de bord</h1>
17
18      {/* Section à chargement rapide */}
19      <TasksOverview stats={tasksStats} />
20
21      <div className="dashboard-grid">
22        {/* Streame indépendamment */}
23        <Suspense fallback={<RecentActivitySkeleton />}>
24          <RecentActivity initialTasks={recentTasks} />
25        </Suspense>
26
27        {/* Peut se charger plus tard */}
28        <Suspense fallback={<MetricsSkeleton />}>
29          <PerformanceMetrics />
30        </Suspense>
31      </div>
32    </div>
33  );
34}
35
36export default DashboardPage;

TasksOverview apparaît tout de suite, tandis que PerformanceMetrics se charge plus tard.

Étape 4 : Composants interactifs avec les nouveaux hooks

La directive 'use client' marque la frontière au-delà de laquelle le code est envoyé au navigateur et peut utiliser un état :

1// components/dashboard/RecentActivity.tsx (Client Component)
2'use client';
3
4import { useState, useTransition, useDeferredValue } from 'react';
5import { Task } from '@/types';
6
7interface RecentActivityProps {
8  initialTasks: Task[];
9}
10
11export function RecentActivity({ initialTasks }: RecentActivityProps) {
12  const [tasks, setTasks] = useState(initialTasks);
13  const [searchQuery, setSearchQuery] = useState('');
14  const [isPending, startTransition] = useTransition();
15
16  // Valeur différée pour la recherche
17  const deferredSearchQuery = useDeferredValue(searchQuery);
18
19  // Filtre les tâches en utilisant deferredValue
20  const filteredTasks = tasks.filter(task =>
21    task.title.toLowerCase().includes(deferredSearchQuery.toLowerCase()) ||
22    task.description.toLowerCase().includes(deferredSearchQuery.toLowerCase())
23  );
24
25  const handleSearch = (query: string) => {
26    setSearchQuery(query);
27
28    // Utilise une transition pour les mises à jour moins prioritaires
29    startTransition(() => {
30      // Opérations supplémentaires pouvant être interrompues
31      updateSearchAnalytics(query);
32    });
33  };
34
35  const refreshTasks = async () => {
36    startTransition(async () => {
37      const newTasks = await fetchRecentTasks();
38      setTasks(newTasks);
39    });
40  };
41
42  return (
43    <div className="recent-activity">
44      <div className="activity-header">
45        <h2>Activité récente</h2>
46        <button
47          onClick={refreshTasks}
48          disabled={isPending}
49          className={isPending ? 'loading' : ''}
50        >
51          {isPending ? 'Rafraîchissement...' : 'Rafraîchir'}
52        </button>
53      </div>
54
55      <SearchInput
56        value={searchQuery}
57        onChange={handleSearch}
58        isPending={isPending}
59      />
60
61      <div className="tasks-list">
62        {filteredTasks.map(task => (
63          <TaskCard
64            key={task.id}
65            task={task}
66            onUpdate={setTasks}
67          />
68        ))}
69
70        {deferredSearchQuery && filteredTasks.length === 0 && (
71          <div className="no-results">
72            Aucun résultat pour « {deferredSearchQuery} »
73          </div>
74        )}
75      </div>
76    </div>
77  );
78}

La liste se filtre selon deferredSearchQuery, donc le champ de recherche ne ralentit pas. Deux remarques : startTransition dans handleSearch n'interrompt pas l'appel à updateSearchAnalytics lui-même, car seuls les rendus causés par des changements d'état peuvent être interrompus. Et setTasks après await devrait, selon react.dev, aller dans un autre startTransition.

Étape 5 : Formulaires avancés avec Server Actions

Le formulaire combine trois hooks. useActionState garde le résultat de l'action, useOptimistic montre l'enregistrement tout de suite, et useFormStatus informe le bouton de l'envoi. Important : useFormStatus s'importe depuis react-dom, et non depuis react. D'abord, la logique :

1// components/forms/TaskForm.tsx (Client Component)
2'use client';
3
4import { useActionState, useEffect, useId, useOptimistic } from 'react';
5import { useFormStatus } from 'react-dom';
6import { Task } from '@/types';
7import { createTask, updateTask } from '@/lib/actions';
8
9interface TaskFormProps {
10  task?: Task;
11  onSuccess?: () => void;
12}
13
14export function TaskForm({ task, onSuccess }: TaskFormProps) {
15  const formId = useId();
16  const titleId = useId();
17  const descriptionId = useId();
18
19  // useActionState pour la gestion de l'état de l'action
20  const [state, formAction] = useActionState(
21    task ? updateTask.bind(null, task.id) : createTask,
22    {
23      success: false,
24      errors: {},
25      data: task || null
26    }
27  );
28
29  // useOptimistic pour un retour instantané
30  const [optimisticState, addOptimistic] = useOptimistic(
31    state,
32    (currentState, optimisticUpdate) => ({
33      ...currentState,
34      ...optimisticUpdate,
35      optimistic: true
36    })
37  );
38
39  const handleSubmit = async (formData: FormData) => {
40    // Mise à jour optimiste
41    addOptimistic({
42      success: true,
43      data: {
44        id: task?.id || 'temp-' + Date.now(),
45        title: formData.get('title') as string,
46        description: formData.get('description') as string,
47        status: formData.get('status') as Task['status'],
48        priority: formData.get('priority') as Task['priority'],
49        createdAt: new Date(),
50        updatedAt: new Date()
51      }
52    });
53
54    // Exécute l'action réelle
55    formAction(formData);
56  };
57
58  // Le nouvel état arrive au rendu suivant, donc on y réagit dans un effet
59  useEffect(() => {
60    if (state.success) {
61      onSuccess?.();
62    }
63  }, [state, onSuccess]);
64

La fonction formAction ne renvoie pas de Promise, et state dans cette fermeture est encore l'ancien état. C'est pourquoi on réagit au succès dans un effet, quand le nouveau state arrive au rendu suivant. Voici la vue du formulaire :

1  return (
2    <form
3      id={formId}
4      action={handleSubmit}
5      className="task-form"
6    >
7      <div className="form-group">
8        <label htmlFor={titleId}>Titre de la tâche</label>
9        <input
10          id={titleId}
11          name="title"
12          type="text"
13          required
14          defaultValue={optimisticState.data?.title || ''}
15          className={optimisticState.errors?.title ? 'error' : ''}
16        />
17        {optimisticState.errors?.title && (
18          <span className="error-message" role="alert">
19            {optimisticState.errors.title}
20          </span>
21        )}
22      </div>
23
24      <div className="form-group">
25        <label htmlFor={descriptionId}>Description</label>
26        <textarea
27          id={descriptionId}
28          name="description"
29          rows={4}
30          defaultValue={optimisticState.data?.description || ''}
31        />
32      </div>
33
34      <div className="form-group">
35        <label>Statut</label>
36        <select name="status" defaultValue={optimisticState.data?.status || 'pending'}>
37          <option value="pending">En attente</option>
38          <option value="in_progress">En cours</option>
39          <option value="completed">Terminée</option>
40        </select>
41      </div>
42
43      <div className="form-group">
44        <label>Priorité</label>
45        <select name="priority" defaultValue={optimisticState.data?.priority || 'medium'}>
46          <option value="low">Basse</option>
47          <option value="medium">Moyenne</option>
48          <option value="high">Haute</option>
49        </select>
50      </div>
51
52      <FormSubmitButton />
53
54      {optimisticState.optimistic && (
55        <div className="optimistic-indicator">
56          Enregistrement...
57        </div>
58      )}
59
60      {optimisticState.success && !optimisticState.optimistic && (
61        <div className="success-message">
62          La tâche a été {task ? 'mise à jour' : 'créée'} !
63        </div>
64      )}
65    </form>
66  );
67}
68
69// Composant utilisant useFormStatus
70function FormSubmitButton() {
71  const { pending } = useFormStatus();
72
73  return (
74    <button
75      type="submit"
76      disabled={pending}
77      className={pending ? 'loading' : ''}
78    >
79      {pending ? 'Enregistrement...' : 'Enregistrer la tâche'}
80    </button>
81  );
82}

FormSubmitButton est un composant séparé, car useFormStatus lit l'état du formulaire parent, donc il doit être appelé à l'intérieur d'un <form>.

Étape 6 : Server Actions

La directive 'use server' fait en sorte que ces fonctions s'exécutent uniquement sur le serveur. On valide avec la bibliothèque Zod :

1// lib/actions.ts
2'use server';
3
4import { revalidatePath } from 'next/cache';
5import { redirect } from 'next/navigation';
6import { z } from 'zod';
7
8const TaskSchema = z.object({
9  title: z.string().min(1, 'Le titre est requis').max(100, 'Le titre est trop long'),
10  description: z.string().optional(),
11  status: z.enum(['pending', 'in_progress', 'completed']),
12  priority: z.enum(['low', 'medium', 'high']),
13  dueDate: z.string().optional().transform(str => str ? new Date(str) : undefined)
14});
15
16export async function createTask(prevState: any, formData: FormData) {
17  try {
18    const validatedFields = TaskSchema.safeParse({
19      title: formData.get('title'),
20      description: formData.get('description'),
21      status: formData.get('status'),
22      priority: formData.get('priority'),
23      dueDate: formData.get('dueDate')
24    });
25
26    if (!validatedFields.success) {
27      return {
28        success: false,
29        errors: z.flattenError(validatedFields.error).fieldErrors,
30        data: null
31      };
32    }
33
34    const task = await db.tasks.create({
35      data: {
36        ...validatedFields.data,
37        id: generateId(),
38        createdAt: new Date(),
39        updatedAt: new Date()
40      }
41    });
42
43    // Revalide le cache
44    revalidatePath('/dashboard');
45    revalidatePath('/tasks');
46
47    return {
48      success: true,
49      errors: {},
50      data: task
51    };
52
53  } catch (error) {
54    return {
55      success: false,
56      errors: { _form: ['Une erreur est survenue lors de la création de la tâche'] },
57      data: null
58    };
59  }
60}
61

safeParse ne lance pas d'exception, il renvoie un objet avec un champ success. On aplatit les erreurs avec z.flattenError, car dans Zod 4 la méthode error.flatten() est marquée comme dépréciée. L'action de mise à jour est similaire :

1export async function updateTask(taskId: string, prevState: any, formData: FormData) {
2  try {
3    const validatedFields = TaskSchema.safeParse({
4      title: formData.get('title'),
5      description: formData.get('description'),
6      status: formData.get('status'),
7      priority: formData.get('priority'),
8      dueDate: formData.get('dueDate')
9    });
10
11    if (!validatedFields.success) {
12      return {
13        success: false,
14        errors: z.flattenError(validatedFields.error).fieldErrors,
15        data: null
16      };
17    }
18
19    const task = await db.tasks.update({
20      where: { id: taskId },
21      data: {
22        ...validatedFields.data,
23        updatedAt: new Date()
24      }
25    });
26
27    revalidatePath(`/tasks/${taskId}`);
28    revalidatePath('/dashboard');
29
30    return {
31      success: true,
32      errors: {},
33      data: task
34    };
35
36  } catch (error) {
37    return {
38      success: false,
39      errors: { _form: ['Une erreur est survenue lors de la mise à jour de la tâche'] },
40      data: null
41    };
42  }
43}

updateTask reçoit taskId comme premier argument grâce à bind dans le formulaire, et revalidatePath rafraîchit les données des pages.

Étape 7 : Patterns Suspense avancés

Les frontières Suspense peuvent s'imbriquer, jusqu'à une seule carte :

1// components/tasks/TasksList.tsx (Server Component)
2import { Suspense } from 'react';
3import { TaskCard } from './TaskCard';
4import { TasksFilter } from './TasksFilter';
5
6async function TasksList({ searchParams }: { searchParams: any }) {
7  return (
8    <div className="tasks-page">
9      <div className="tasks-header">
10        <h1>Tâches</h1>
11
12        {/* Le filtre ne nécessite pas de Suspense - se charge rapidement */}
13        <TasksFilter />
14      </div>
15
16      <div className="tasks-content">
17        {/* Liste principale des tâches */}
18        <Suspense fallback={<TasksListSkeleton />}>
19          <TasksGrid searchParams={searchParams} />
20        </Suspense>
21
22        {/* Barre latérale avec des infos supplémentaires */}
23        <aside className="tasks-sidebar">
24          <Suspense fallback={<StatsSkeleton />}>
25            <TasksStatistics />
26          </Suspense>
27
28          <Suspense fallback={<ActivitySkeleton />}>
29            <RecentActivity />
30          </Suspense>
31        </aside>
32      </div>
33    </div>
34  );
35}
36
37// Composant avec un long temps de chargement
38async function TasksGrid({ searchParams }: { searchParams: any }) {
39  // Simule une requête longue
40  const tasks = await getFilteredTasks(searchParams);
41
42  return (
43    <div className="tasks-grid">
44      {tasks.map(task => (
45        <Suspense
46          key={task.id}
47          fallback={<TaskCardSkeleton />}
48        >
49          <TaskCard task={task} />
50        </Suspense>
51      ))}
52    </div>
53  );
54}

Depuis Next.js 15, searchParams sur une page est une Promise, donc il faut la lire avec await avant de l'utiliser.

Étape 8 : Accessibilité avec useId

Un champ de formulaire universel injecte des identifiants dans son enfant :

1// components/ui/FormField.tsx
2'use client';
3
4import { useId, forwardRef, cloneElement } from 'react';
5
6interface FormFieldProps {
7  label: string;
8  error?: string;
9  help?: string;
10  required?: boolean;
11  children: React.ReactNode;
12}
13
14export const FormField = forwardRef<HTMLDivElement, FormFieldProps>(
15  ({ label, error, help, required, children }, ref) => {
16    const fieldId = useId();
17    const errorId = useId();
18    const helpId = useId();
19
20    return (
21      <div ref={ref} className="form-field">
22        <label
23          htmlFor={fieldId}
24          className={required ? 'required' : ''}
25        >
26          {label}
27          {required && <span aria-hidden="true">*</span>}
28        </label>
29
30        <div className="field-input">
31          {cloneElement(children as React.ReactElement, {
32            id: fieldId,
33            'aria-describedby': [
34              help ? helpId : null,
35              error ? errorId : null
36            ].filter(Boolean).join(' ') || undefined,
37            'aria-invalid': error ? 'true' : undefined,
38            required
39          })}
40        </div>
41
42        {help && (
43          <div id={helpId} className="field-help">
44            {help}
45          </div>
46        )}
47
48        {error && (
49          <div
50            id={errorId}
51            className="field-error"
52            role="alert"
53            aria-live="polite"
54          >
55            {error}
56          </div>
57        )}
58      </div>
59    );
60  }
61);
62
63FormField.displayName = 'FormField';

Dans React 19, ref est une prop ordinaire d'un composant fonction, et forwardRef doit être déprécié à l'avenir, donc dans du nouveau code tu peux recevoir ref directement.

Étape 9 : Surveillance des performances

Pour finir, un contexte qui collecte des métriques de performance :

1// components/providers/PerformanceProvider.tsx
2'use client';
3
4import { createContext, useContext, useEffect, useState } from 'react';
5
6interface PerformanceMetrics {
7  renderTime: number;
8  interactionTime: number;
9  suspenseTime: number;
10}
11
12const PerformanceContext = createContext<{
13  metrics: PerformanceMetrics;
14  recordMetric: (type: keyof PerformanceMetrics, time: number) => void;
15}>({
16  metrics: { renderTime: 0, interactionTime: 0, suspenseTime: 0 },
17  recordMetric: () => {}
18});
19
20export function PerformanceProvider({ children }: { children: React.ReactNode }) {
21  const [metrics, setMetrics] = useState<PerformanceMetrics>({
22    renderTime: 0,
23    interactionTime: 0,
24    suspenseTime: 0
25  });
26
27  const recordMetric = (type: keyof PerformanceMetrics, time: number) => {
28    setMetrics(prev => ({
29      ...prev,
30      [type]: time
31    }));
32
33    // Envoie les métriques au système d'analytics
34    if (time > getThreshold(type)) {
35      analytics.track('performance_issue', {
36        type,
37        time,
38        url: window.location.pathname
39      });
40    }
41  };
42
43  useEffect(() => {
44    // Observe les Web Vitals
45    const observer = new PerformanceObserver((list) => {
46      for (const entry of list.getEntries()) {
47        if (entry.entryType === 'measure') {
48          recordMetric('renderTime', entry.duration);
49        }
50      }
51    });
52
53    observer.observe({ entryTypes: ['measure'] });
54
55    return () => observer.disconnect();
56  }, []);
57
58  return (
59    <PerformanceContext.Provider value={{ metrics, recordMetric }}>
60      {children}
61    </PerformanceContext.Provider>
62  );
63}
64
65export const usePerformance = () => useContext(PerformanceContext);
66
67function getThreshold(type: keyof PerformanceMetrics): number {
68  const thresholds = {
69    renderTime: 100,      // 100ms
70    interactionTime: 50,  // 50ms
71    suspenseTime: 1000    // 1s
72  };
73  return thresholds[type];
74}

PerformanceObserver écoute ici les entrées de type measure, c'est-à-dire tes propres mesures, et non les Web Vitals. Pour les Core Web Vitals, utilise la bibliothèque web-vitals.

Résumé du projet

Ce projet démontre :

  1. Concurrent Features : useTransition, useDeferredValue pour une UI réactive
  2. Gestion des formulaires : useActionState, useFormStatus avec Server Actions
  3. Accessibilité : useId pour des identifiants uniques
  4. Mises à jour optimistes : useOptimistic pour un retour instantané
  5. Server Components : rendu côté serveur avec récupération de données en parallèle
  6. Patterns Suspense : chargement hiérarchique avec streaming
  7. Performance : surveillance et optimisation des performances
  8. Gestion des erreurs : Error Boundaries granulaires
  9. Sûreté des types : utilisation complète de TypeScript

Prochaines étapes pour aller plus loin :

  1. Ajouter une synchronisation en temps réel avec les WebSockets
  2. Implémenter le support hors ligne avec les Service Workers
  3. Ajouter des filtres et un tri avancés
  4. Créer un système de permissions utilisateur
  5. Intégrer un système de notifications push
  6. Ajouter l'export de données vers différents formats
  7. Implémenter le mode sombre avec du CSS-in-JS
  8. Créer une application mobile avec React Native

Ce projet constitue une base solide pour construire des applications React modernes exploitant les dernières fonctionnalités du framework.

Mon conseil : construis étape par étape et lance l'application après chaque étape. Retiens : une bonne mission, c'est quand chaque système du vaisseau connaît sa tâche et que le contrôle de mission les voit tous à la fois.

Code de cette leçon : App.jsx
1import React, {
2  useState, useMemo, useCallback, memo,
3  useTransition, useDeferredValue, Suspense, lazy, createContext, use,
4} from 'react';
5
6// ========================
7// CONTEXTES DE L'APPLICATION
8// ========================
9const ThemeContext = createContext('dark');
10
11// ========================
12// DONNÉES DE SIMULATION
13// ========================
14function generateCrewMembers(count) {
15  const roles = ['Pilote', 'Navigateur', 'Engineer', 'Médecin', 'Scientifique', 'Technicien'];
16  const statuses = ['Actif', 'En mission', 'Repos', 'Formation'];
17  const members = [];
18  for (let i = 0; i < count; i++) {
19    members.push({
20      id: i + 1,
21      name: `Cosmonaute-${(i + 1).toString().padStart(3, '0')}`,
22      role: roles[i % roles.length],
23      status: statuses[i % statuses.length],
24      missions: Math.floor(Math.random() * 50),
25      rating: (3 + Math.random() * 2).toFixed(1),
26    });
27  }
28  return members;
29}
30
31const ALL_CREW = generateCrewMembers(200);
32
33// ========================
34// COMPOSANTS OPTIMISÉS
35// ========================
36
37// React.memo - évite les re-rendus inutiles
38const StatCard = memo(function StatCard({ label, value, color, icon }) {
39  return (
40    <div style={{
41      padding: '16px', borderRadius: '10px',
42      background: `${color}11`, border: `1px solid ${color}33`,
43      textAlign: 'center', minWidth: '120px',
44    }}>
45      <div style={{ fontSize: '24px', marginBottom: '4px' }}>{icon}</div>
46      <div style={{ fontSize: '22px', fontWeight: 'bold', color }}>{value}</div>
47      <div style={{ fontSize: '12px', color: '#778da9', marginTop: '4px' }}>{label}</div>
48    </div>
49  );
50});
51
52// Ligne de tableau mémoïsée
53const CrewRow = memo(function CrewRow({ member, onSelect }) {
54  const statusColor = {
55    'Actif': '#4ecdc4',
56    'En mission': '#ffd700',
57    'Repos': '#778da9',
58    'Formation': '#00d4ff',
59  }[member.status] || '#778da9';
60
61  return (
62    <div
63      onClick={() => onSelect(member)}
64      style={{
65        display: 'grid', gridTemplateColumns: '1fr 100px 90px 60px 50px',
66        padding: '10px 14px', fontSize: '13px',
67        borderBottom: '1px solid #1a2a3c', cursor: 'pointer',
68        transition: 'background 0.15s',
69      }}
70      onMouseEnter={e => e.currentTarget.style.background = 'rgba(0,212,255,0.05)'}
71      onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
72    >
73      <span style={{ color: '#e0e1dd' }}>{member.name}</span>
74      <span style={{ color: '#778da9' }}>{member.role}</span>
75      <span style={{ color: statusColor }}>{member.status}</span>
76      <span style={{ color: '#c0c8d0', textAlign: 'center' }}>{member.missions}</span>
77      <span style={{ color: '#ffd700', textAlign: 'center' }}>{member.rating}</span>
78    </div>
79  );
80});
81
82// TODO 1: Wrap DetailPanel component in React.memo
83// Il devrait se rendre uniquement quand selectedMember change
84function DetailPanel({ selectedMember }) {
85  if (!selectedMember) {
86    return (
87      <div style={{
88        padding: '40px', textAlign: 'center', color: '#556677',
89        background: 'rgba(255,255,255,0.02)', borderRadius: '10px',
90      }}>
91        Sélectionne un membre de l'équipage pour voir les détails
92      </div>
93    );
94  }
95  return (
96    <div style={{
97      padding: '20px', background: 'rgba(0, 212, 255, 0.04)',
98      borderRadius: '10px', border: '1px solid #1a3a5c',
99    }}>
100      <h3 style={{ color: '#00d4ff', margin: '0 0 12px' }}>{selectedMember.name}</h3>
101      <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px', fontSize: '13px' }}>
102        <div><span style={{ color: '#778da9' }}>Rôle :</span> <span style={{ color: '#e0e1dd' }}>{selectedMember.role}</span></div>
103        <div><span style={{ color: '#778da9' }}>Statut :</span> <span style={{ color: '#4ecdc4' }}>{selectedMember.status}</span></div>
104        <div><span style={{ color: '#778da9' }}>Missions :</span> <span style={{ color: '#e0e1dd' }}>{selectedMember.missions}</span></div>
105        <div><span style={{ color: '#778da9' }}>Note :</span> <span style={{ color: '#ffd700' }}>{selectedMember.rating}/5.0</span></div>
106      </div>
107    </div>
108  );
109}
110
111// ========================
112// TABLEAU DE BORD PRINCIPAL
113// ========================
114export default function App() {
115  const [searchQuery, setSearchQuery] = useState('');
116  const [statusFilter, setStatusFilter] = useState('all');
117  const [selectedMember, setSelectedMember] = useState(null);
118  const [isPending, startTransition] = useTransition();
119
120  // useDeferredValue - deferred search
121  const deferredSearch = useDeferredValue(searchQuery);
122
123  // useMemo - memoizing the filtered list
124  const filteredCrew = useMemo(() => {
125    return ALL_CREW.filter(member => {
126      const matchesSearch = member.name.toLowerCase().includes(deferredSearch.toLowerCase()) ||
127                            member.role.toLowerCase().includes(deferredSearch.toLowerCase());
128      const matchesStatus = statusFilter === 'all' || member.status === statusFilter;
129      return matchesSearch && matchesStatus;
130    });
131  }, [deferredSearch, statusFilter]);
132
133  // useMemo - calcul des statistiques
134  const stats = useMemo(() => ({
135    total: ALL_CREW.length,
136    active: ALL_CREW.filter(m => m.status === 'Actif').length,
137    onMission: ALL_CREW.filter(m => m.status === 'En mission').length,
138    avgRating: (ALL_CREW.reduce((sum, m) => sum + parseFloat(m.rating), 0) / ALL_CREW.length).toFixed(1),
139  }), []);
140
141  // useCallback - référence de fonction stable pour les composants memo
142  const handleSelect = useCallback((member) => {
143    setSelectedMember(member);
144  }, []);
145
146  // TODO 2: Utilise useCallback pour handleStatusChange
147  // pour éviter les re-rendus des composants de filtrage
148  const handleStatusChange = (status) => {
149    startTransition(() => {
150      setStatusFilter(status);
151    });
152  };
153
154  // TODO 3: Utilise useMemo pour calculer topPerformers
155  // (les 5 meilleurs membres selon la note, dans la liste filtrée)
156  // sort modifie le tableau sur place, donc on trie une copie : sinon le tableau s’ordonnerait aussi selon la note
157  const topPerformers = [...filteredCrew]
158    .sort((a, b) => parseFloat(b.rating) - parseFloat(a.rating))
159    .slice(0, 5);
160
161  const STATUSES = ['all', 'Actif', 'En mission', 'Repos', 'Formation'];
162
163  return (
164    <div style={{
165      minHeight: '100vh',
166      background: 'linear-gradient(135deg, #0a0e27 0%, #1a1a3e 100%)',
167      padding: '20px', fontFamily: 'monospace', color: '#e0e1dd',
168    }}>
169      <h1 style={{ color: '#00d4ff', textAlign: 'center', margin: '0 0 4px' }}>
170        Centre de Commandement de la Flotte
171      </h1>
172      <p style={{ color: '#778da9', textAlign: 'center', marginBottom: '20px', fontSize: '13px' }}>
173        Tableau de bord avec React.memo | useMemo | useCallback | useTransition | useDeferredValue
174      </p>
175
176      {/* Statistiques */}
177      <div style={{
178        display: 'flex', gap: '12px', justifyContent: 'center',
179        marginBottom: '20px', flexWrap: 'wrap',
180      }}>
181        <StatCard icon="◆" label="Équipage total" value={stats.total} color="#00d4ff" />
182        <StatCard icon="OK" label="Actifs" value={stats.active} color="#4ecdc4" />
183        <StatCard icon="▲" label="En mission" value={stats.onMission} color="#ffd700" />
184        <StatCard icon="◆" label="Note moyenne" value={stats.avgRating} color="#ff6b4a" />
185      </div>
186
187      <div style={{ maxWidth: '800px', margin: '0 auto' }}>
188        {/* Recherche et filtres */}
189        <div style={{ display: 'flex', gap: '8px', marginBottom: '12px', flexWrap: 'wrap' }}>
190          <input
191            value={searchQuery}
192            onChange={e => setSearchQuery(e.target.value)}
193            placeholder="Rechercher un membre de l'équipage..."
194            style={{
195              flex: 1, minWidth: '200px', padding: '10px 14px',
196              background: '#0d1b2a', border: '1px solid #1a3a5c',
197              borderRadius: '8px', color: '#e0e1dd', fontSize: '14px',
198            }}
199          />
200          {STATUSES.map(status => (
201            <button
202              key={status}
203              onClick={() => handleStatusChange(status)}
204              style={{
205                padding: '8px 14px',
206                background: statusFilter === status ? '#1a3a5c' : 'transparent',
207                border: `1px solid ${statusFilter === status ? '#00d4ff' : '#1a3a5c'}`,
208                borderRadius: '6px', fontSize: '12px',
209                color: statusFilter === status ? '#00d4ff' : '#778da9',
210                cursor: 'pointer',
211              }}
212            >
213              {status === 'all' ? 'Tous' : status}
214            </button>
215          ))}
216        </div>
217
218        {isPending && (
219          <div style={{ color: '#00d4ff', fontSize: '12px', marginBottom: '8px' }}>
220            Filtrage...
221          </div>
222        )}
223
224        <div style={{ display: 'grid', gridTemplateColumns: '1fr 280px', gap: '16px' }}>
225          {/* Tableau de l'équipage */}
226          <div>
227            <div style={{
228              display: 'grid', gridTemplateColumns: '1fr 100px 90px 60px 50px',
229              padding: '8px 14px', fontSize: '11px', color: '#556677',
230              borderBottom: '2px solid #1a3a5c', textTransform: 'uppercase',
231            }}>
232              <span>Nom</span><span>Rôle</span><span>Statut</span>
233              <span style={{ textAlign: 'center' }}>Missions</span>
234              <span style={{ textAlign: 'center' }}>Note</span>
235            </div>
236            <div style={{ maxHeight: '350px', overflow: 'auto' }}>
237              {filteredCrew.slice(0, 50).map(member => (
238                <CrewRow
239                  key={member.id}
240                  member={member}
241                  onSelect={handleSelect}
242                />
243              ))}
244            </div>
245            <div style={{ padding: '8px 14px', fontSize: '12px', color: '#556677' }}>
246              Affichage de {Math.min(50, filteredCrew.length)} sur {filteredCrew.length} résultats
247            </div>
248          </div>
249
250          {/* Panneau latéral */}
251          <div>
252            <DetailPanel selectedMember={selectedMember} />
253
254            <div style={{ marginTop: '16px' }}>
255              <h4 style={{ color: '#ffd700', margin: '0 0 8px', fontSize: '13px' }}>
256                Top 5 - meilleure note
257              </h4>
258              {topPerformers.map((m, i) => (
259                <div key={m.id} style={{
260                  display: 'flex', justifyContent: 'space-between',
261                  padding: '6px 10px', fontSize: '12px',
262                  background: i === 0 ? 'rgba(255, 215, 0, 0.06)' : 'transparent',
263                  borderRadius: '4px',
264                }}>
265                  <span style={{ color: '#c0c8d0' }}>
266                    {i + 1}. {m.name}
267                  </span>
268                  <span style={{ color: '#ffd700' }}>{m.rating}</span>
269                </div>
270              ))}
271            </div>
272          </div>
273        </div>
274      </div>
275    </div>
276  );
277}

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

Articles utiles