Cours JavaScript et React · Module 15: Patterns et architecture
Pattern Container/Presentational - Séparer la logique de la vue
Dans cette leçon6
La liste de l'équipage sur la passerelle récupère les données d'une API, retient la personne sélectionnée, affiche un spinner et dessine les cartes, le tout dans un seul composant. Quand le Commandant Nova demande la même liste dans une fenêtre modale, mais avec une autre source de données, il faut copier tout le composant avec la récupération. Dans la Station spatiale, chaque système a deux couches : la logique de pilotage (centre de commandement) et l'affichage (panneaux d'information). En React, on applique une séparation analogue : le pattern Container/Presentational.
L'idée derrière le pattern
Le pattern divise les composants en deux types :
- Container (Smart Component) : gère l'état, récupère les données, s'occupe de la logique métier. Il n'a ni balisage ni styles propres, il affiche seulement des composants de présentation et leur transmet les données.
- Presentational (Dumb Component) : est uniquement responsable du rendu de l'UI. Il reçoit les données par les props et ne sait pas d'où elles viennent.
C'est Dan Abramov qui a popularisé ce pattern en 2015. En 2019, il a ajouté à son article une note expliquant qu'il ne recommande plus de découper les composants de force de cette manière, car les hooks permettent de séparer la logique sans division artificielle. L'idée elle-même reste pourtant l'une des plus importantes de React, alors regardons les deux couches.
Composant Presentational
On commence par l'affichage. Un composant de présentation est simple : il reçoit des données et une fonction pour gérer les clics, et ne récupère rien lui-même :
1// CrewList.jsx : composant presentational
2function CrewList({ members, onSelect, selectedId }) {
3 return (
4 <ul className="crew-list">
5 {members.map(member => (
6 <li
7 key={member.id}
8 className={member.id === selectedId ? 'active' : ''}
9 onClick={() => onSelect(member.id)}
10 >
11 <span className="name">{member.name}</span>
12 <span className="role">{member.role}</span>
13 </li>
14 ))}
15 </ul>
16 );
17}CrewList ne sait pas si les données viennent d'une API, d'un test ou du localStorage. Il ne change pas non plus la sélection tout seul : il appelle seulement onSelect et laisse la décision à son parent.
Caractéristiques d'un composant de présentation :
- N'a pas d'état propre (ou seulement un état d'UI, comme le survol)
- Reçoit les données et les callbacks par les props
- Facile à tester : il suffit de passer des props
- Réutilisable dans différents contextes
Composant Container
Place maintenant au centre de commandement. Le container gère l'état et la logique : il garde la liste de l'équipage, le drapeau de chargement et l'identifiant de la personne sélectionnée, et récupère les données après le montage :
1// CrewContainer.jsx : composant container
2function CrewContainer() {
3 const [members, setMembers] = useState([]);
4 const [selectedId, setSelectedId] = useState(null);
5 const [loading, setLoading] = useState(true);
6
7 useEffect(() => {
8 fetch('/api/crew')
9 .then(res => res.json())
10 .then(data => {
11 setMembers(data);
12 setLoading(false);
13 });
14 }, []);La seconde moitié du container gère le choix d'un membre d'équipage et transmet tout vers le bas par les props, au composant de présentation :
1 const handleSelect = (id) => {
2 setSelectedId(id);
3 };
4
5 if (loading) return <Spinner />;
6
7 return (
8 <CrewList
9 members={members}
10 onSelect={handleSelect}
11 selectedId={selectedId}
12 />
13 );
14}Remarque ce qui manque ici : pas une seule classe CSS, pas un seul <li>. Le container n'affiche que Spinner et CrewList. Quand un designer transformera la liste en grille de cartes, le container ne changera pas.
Évolution du pattern - Custom Hooks
Aujourd'hui, au lieu de composants container, on écrit plus souvent des custom hooks. Un custom hook est une fonction dont le nom commence par use et qui appelle d'autres hooks. On y déplace toute la logique du container :
1// useCrew.js : un hook à la place du composant container
2function useCrew() {
3 const [members, setMembers] = useState([]);
4 const [selectedId, setSelectedId] = useState(null);
5 const [loading, setLoading] = useState(true);
6
7 useEffect(() => {
8 fetch('/api/crew')
9 .then(res => res.json())
10 .then(data => {
11 setMembers(data);
12 setLoading(false);
13 });
14 }, []);
15
16 const selectMember = (id) => setSelectedId(id);
17
18 return { members, selectedId, selectMember, loading };
19}Le hook renvoie un objet avec des données et des actions, mais il ne renvoie pas de JSX. C'est pourquoi le composant qui l'utilise reste court et lisible :
1// Utilisation : logique dans le hook, UI dans le composant
2function CrewPage() {
3 const { members, selectedId, selectMember, loading } = useCrew();
4
5 if (loading) return <Spinner />;
6
7 return <CrewList members={members} onSelect={selectMember} selectedId={selectedId} />;
8}La logique est la même que dans CrewContainer, seul son emplacement a changé : au lieu d'un composant séparé, on a une fonction que tu peux appeler dans n'importe quel composant. Un hook peut aussi renvoyer des valeurs dérivées, par exemple un selectedMember trouvé avec members.find(). La documentation de React conseille de calculer ces données pendant le rendu plutôt que de les garder dans un état séparé, car elles ne peuvent alors pas se désynchroniser de l'original. Les couches s'empilent depuis le bas : les données de l'API, le hook avec la logique et l'état, le composant container comme connecteur (ici CrewPage), le composant de présentation et l'UI rendue.
Avantages de la séparation
Séparer la logique de la vue apporte plusieurs avantages précieux dans les gros projets.
1. Tests faciles
Les composants de présentation peuvent être testés sans simuler d'API. Il suffit de leur donner des props et de vérifier ce qui apparaît à l'écran :
1// Test d'un composant presentational : zéro mock !
2test('affiche la liste de l’équipage', () => {
3 const members = [
4 { id: 1, name: 'Anna', role: 'Pilote' },
5 { id: 2, name: 'Jan', role: 'Ingénieur' },
6 ];
7
8 render(<CrewList members={members} onSelect={() => {}} selectedId={null} />);
9
10 expect(screen.getByText('Anna')).toBeInTheDocument();
11 expect(screen.getByText('Ingénieur')).toBeInTheDocument();
12});La logique se teste à part. renderHook de React Testing Library exécute le hook sans aucune vue, et waitFor attend que les données arrivent :
1// Test d'un hook : on teste la logique séparément
2test('useCrew récupère et renvoie l’équipage', async () => {
3 const { result } = renderHook(() => useCrew());
4 await waitFor(() => expect(result.current.members).toHaveLength(5));
5});Ce second test a quand même besoin d'un fetch simulé, par exemple avec jest.fn() ou la bibliothèque MSW, car le hook récupère vraiment des données. Ici, l'API simulée renvoie cinq personnes. Seul le test du composant de présentation se passe de mocks.
2. Réutilisabilité
Le composant CrewList peut servir dans différents contextes : sur un tableau de bord, dans une modale, dans un panneau latéral, à chaque fois avec une autre source de données.
3. Développement indépendant
Un designer peut travailler sur le composant de présentation (apparence, animations) pendant qu'un développeur travaille sur le hook (logique, API), en même temps et sans conflits.
Quand utiliser ce pattern ?
- Oui, quand un composant récupère des données d'une API et les affiche : la logique va dans un hook, la vue dans un composant de présentation
- Oui, quand la même logique est utilisée à plusieurs endroits
- Non pour des composants simples avec peu de logique : trop d'abstraction est aussi un problème
Mon conseil : ne crée pas de fichier CrewContainer.jsx par habitude. Commence par un seul composant, et quand la logique grossit ou qu'un deuxième écran a besoin des mêmes données, extrais-la dans un hook. L'idée de ce pattern est le fondement d'une bonne architecture React. Séparer « quoi afficher » de « où prendre les données » rend le code plus prévisible, testable et flexible, comme des systèmes de navigation et d'affichage séparés sur un vaisseau spatial. Dans la prochaine leçon, on ira plus loin en construisant des composants headless qui confient toute l'apparence au consommateur.
Retiens : le centre de commandement obtient les données, et le panneau se contente de les afficher.
Code de cette leçon : App.jsx
1import React, { useState, useEffect } from 'react';
2
3// Les rôles restent en anglais dans les données (ils forment les classes CSS role-...), l’écran affiche des libellés français
4const ROLE_LABELS = { captain: 'Capitaine', medic: 'Médecin', engineer: 'Ingénieur', navigator: 'Navigateur', scientist: 'Scientifique' };
5
6// === COMPOSANT PRESENTATIONAL ===
7// Affichage pur, pas de récupération de données, pas de logique métier
8function CrewList({ members, selectedId, onSelect }) {
9 return (
10 <div className="crew-list">
11 {members.map(member => (
12 <div
13 key={member.id}
14 className={`crew-card ${member.id === selectedId ? 'selected' : ''}`}
15 onClick={() => onSelect(member.id)}
16 >
17 <div className="avatar">{member.name[0]}</div>
18 <div className="info">
19 <h4>{member.name}</h4>
20 <span className={`role role-${member.role}`}>{ROLE_LABELS[member.role]}</span>
21 </div>
22 <span className={`status ${member.active ? 'active' : 'inactive'}`}>
23 {member.active ? 'En service' : 'Hors service'}
24 </span>
25 </div>
26 ))}
27 </div>
28 );
29}
30
31// === PRESENTATIONAL : Vue détaillée ===
32function CrewDetail({ member }) {
33 if (!member) return <div className="detail-empty">Sélectionne un membre d’équipage</div>;
34
35 return (
36 <div className="crew-detail">
37 <div className="detail-avatar">{member.name[0]}</div>
38 <h2>{member.name}</h2>
39 <p className="detail-role">{ROLE_LABELS[member.role]}</p>
40 <div className="stats">
41 <div className="stat"><span>Missions</span><strong>{member.missions}</strong></div>
42 <div className="stat"><span>Ancienneté</span><strong>{member.exp} ans</strong></div>
43 <div className="stat"><span>Note</span><strong>{member.rating}/5</strong></div>
44 </div>
45 </div>
46 );
47}
48
49// === CUSTOM HOOK (remplacement moderne du container) ===
50function useCrew() {
51 const [members, setMembers] = useState([]);
52 const [selectedId, setSelectedId] = useState(null);
53 const [loading, setLoading] = useState(true);
54
55 useEffect(() => {
56 // Simule un fetch API
57 const timer = setTimeout(() => {
58 setMembers([
59 { id: 1, name: 'Commandant Nova', role: 'captain', active: true, missions: 42, exp: 15, rating: 5 },
60 { id: 2, name: 'Dr Stellar', role: 'medic', active: true, missions: 28, exp: 8, rating: 4 },
61 { id: 3, name: 'Astro', role: 'engineer', active: false, missions: 35, exp: 12, rating: 5 },
62 { id: 4, name: 'Ra', role: 'navigator', active: true, missions: 50, exp: 20, rating: 5 },
63 { id: 5, name: 'Nebula', role: 'scientist', active: true, missions: 19, exp: 5, rating: 4 },
64 ]);
65 setLoading(false);
66 }, 800);
67 // Nettoyage : si le composant disparaît avant la fin, on annule le minuteur
68 return () => clearTimeout(timer);
69 }, []);
70
71 const selectedMember = members.find(m => m.id === selectedId) || null;
72
73 return { members, selectedId, setSelectedId, selectedMember, loading };
74}
75
76// === COMPOSANT PAGE ===
77export default function App() {
78 const { members, selectedId, setSelectedId, selectedMember, loading } = useCrew();
79
80 if (loading) {
81 return <div className="loading"><div className="spinner" /><p>Chargement des données de l’équipage...</p></div>;
82 }
83
84 return (
85 <div className="app">
86 <h1>Pattern Container / Presentational</h1>
87 <p className="subtitle">La logique dans le hook, l’UI dans les composants</p>
88 <div className="layout">
89 <div className="list-panel">
90 <h3>Équipage ({members.length})</h3>
91 <CrewList members={members} selectedId={selectedId} onSelect={setSelectedId} />
92 </div>
93 <div className="detail-panel">
94 <CrewDetail member={selectedMember} />
95 </div>
96 </div>
97 </div>
98 );
99}Tu as repéré une erreur dans cette leçon ?
Teste tes connaissances
Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.
1. À quoi sert la prop « children » dans le contexte du pattern de composition ?
2. Quelle caractéristique n'est PAS typique d'un composant Presentational ?
Tâches pratiques dans le jeu
- Éditeur de code
Termine deux composants construits par composition. SpaceCard est un conteneur (containment). ___BLANK1___ : la couleur de la bordure est la valeur de l'objet VARIANT_COLORS pour la prop variant (info, warning ou danger). ___BLANK2___ : le corps de la carte affiche tout ce que le parent écrit entre <SpaceCard> et </SpaceCard>. SpaceLayout a trois slots : ___BLANK3___ affiche dans <header> l'élément de la prop header, ___BLANK4___ affiche dans <aside> l'élément de la prop sidebar, et les enfants vont dans <main>.
- Clique dans l'ordre
Range le code du composant spécialisé LaunchButton qui rend SpaceButton avec des props prédéfinies :
- Éditeur de code
Sépare la logique de la vue. Le hook usePlanets reçoit une fonction loadPlanets (par défaut l'API simulée fetchPlanets) et charge les données dans useEffect. ___BLANK1___ : enregistre les données reçues dans l'état planets. ___BLANK2___ : selectedPlanet est la planète dont l'id est égal à selectedId, calculée pendant le rendu à partir du tableau planets (null quand rien n'est choisi). PlanetList est un composant presentational : ___BLANK3___ appelle onSelect avec l'id de cette planète au clic sur son bouton. ___BLANK4___ : App passe à PlanetList la fonction du hook qui change la sélection.