Cours JavaScript et React · Module 9: Hooks React modernes
Projet : Application React 18+ avancée
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
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
Formulaires avancés
- useFormStatus et useActionState
- Server Actions
- Validation en temps réel
Système de notifications
- useId pour l'accessibilité
- Rendu concurrent
- Amélioration progressive
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 TypeScriptLe 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]);
64La 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}
61safeParse 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 :
- Concurrent Features : useTransition, useDeferredValue pour une UI réactive
- Gestion des formulaires : useActionState, useFormStatus avec Server Actions
- Accessibilité : useId pour des identifiants uniques
- Mises à jour optimistes : useOptimistic pour un retour instantané
- Server Components : rendu côté serveur avec récupération de données en parallèle
- Patterns Suspense : chargement hiérarchique avec streaming
- Performance : surveillance et optimisation des performances
- Gestion des erreurs : Error Boundaries granulaires
- Sûreté des types : utilisation complète de TypeScript
Prochaines étapes pour aller plus loin :
- Ajouter une synchronisation en temps réel avec les WebSockets
- Implémenter le support hors ligne avec les Service Workers
- Ajouter des filtres et un tri avancés
- Créer un système de permissions utilisateur
- Intégrer un système de notifications push
- Ajouter l'export de données vers différents formats
- Implémenter le mode sombre avec du CSS-in-JS
- 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 ?