Cours JavaScript et React · Module 15: Patterns et architecture
Atomic Design en React - Construire une station à partir d'atomes
Dans cette leçon8
La station orbitale compte maintenant vingt écrans. Chacun a un bouton, une barre de progression et un badge de statut, mais chaque équipe les a écrits à sa manière : ici la barre fait six pixels de haut, là huit, et l'alarme rouge existe en trois teintes différentes. Quand le Commandant Nova demande que tous les badges critiques se ressemblent, tu les cherches dans quarante fichiers. Le problème n'est pas un composant en particulier, mais l'absence d'un plan qui dit de quelles pièces se compose l'interface. Ce plan, c'est l'Atomic Design.
Les cinq niveaux de l'Atomic Design
L'Atomic Design est une façon de penser une interface que Brad Frost a décrite en 2013, puis développée dans son livre « Atomic Design ». Il emprunte ses noms à la nature : les atomes s'assemblent en molécules, et les molécules en organismes. Sur la station, cela donne :
- Atomes : les plus petites pièces, qu'il n'y a aucun sens à découper davantage, comme un boulon, un voyant ou un interrupteur. En React, ce sont
Button,Input,BadgeouProgressBar. - Molécules : quelques atomes qui remplissent ensemble une seule fonction, comme un interrupteur avec son voyant et son étiquette. Par exemple,
SearchBar, c'est unInputet unButton. - Organismes : des sections autonomes de l'interface, faites de molécules et d'atomes, comme tout le panneau des systèmes sur la passerelle.
- Templates : la mise en page avec ses slots, mais sans vraies données, comme un plan du pont qui indique où se placera chaque panneau.
- Pages : un template rempli de vraies données, c'est-à-dire le pont le jour de la mission.
L'ordre des niveaux est toujours le même, du plus petit au plus grand : atomes, molécules, organismes, templates, pages. Frost souligne pourtant qu'il s'agit d'un modèle et non d'un processus étape par étape : tu peux travailler sur plusieurs niveaux en même temps.
Atomes - les plus petites pièces
Un atome ne sait pas où il sera utilisé. Il reçoit ses données par les props et a toujours le même aspect. Le plus simple est Badge, une étiquette colorée avec un texte court :
1// atoms/Badge.jsx
2function Badge({ children, color = 'blue' }) {
3 return <span className={`badge badge-${color}`}>{children}</span>;
4}La couleur arrive de l'extérieur sous forme de nom de variante, pas de code couleur, donc la teinte elle-même se règle à un seul endroit : dans les styles de la classe badge-red. Quand l'alarme doit changer de teinte, tu corriges une ligne de CSS, pas quarante fichiers.
ProgressBar est plus intéressant. Visuellement, ce sont deux rectangles, mais un lecteur d'écran doit savoir qu'il s'agit d'une barre de progression, ce qu'elle mesure et quelle est sa valeur. C'est pourquoi la barre reçoit le rôle progressbar, une description dans aria-label et trois attributs numériques :
1// atoms/ProgressBar.jsx
2function ProgressBar({ value, label }) {
3 return (
4 <div
5 className="progress"
6 role="progressbar"
7 aria-label={label}
8 aria-valuenow={value}
9 aria-valuemin={0}
10 aria-valuemax={100}
11 >
12 <div className="progress-fill" style={{ width: `${value}%` }} />
13 </div>
14 );
15}aria-valuenow donne la valeur actuelle, aria-valuemin et aria-valuemax son intervalle. La largeur du remplissage est le même nombre écrit en pourcentage, donc l'aspect et la description pour le lecteur d'écran ne divergent jamais. Le navigateur possède aussi un élément <progress> tout prêt. On construit sa propre barre avec le rôle progressbar quand on veut maîtriser entièrement son aspect, et il faut alors la décrire soi-même.
Molécules - des atomes avec une seule mission
Une molécule combine quelques atomes en quelque chose qui a déjà un sens. L'exemple classique est la barre de recherche : l'atome Input, un champ de texte au style de la station, et l'atome Button servent ensemble à une seule chose, chercher :
1// molecules/SearchBar.jsx
2function SearchBar({ onSearch }) {
3 const [query, setQuery] = useState('');
4
5 return (
6 <div className="search-bar">
7 <Input value={query} onChange={(e) => setQuery(e.target.value)} />
8 <Button onClick={() => onSearch(query)}>Rechercher</Button>
9 </div>
10 );
11}SearchBar retient le texte saisi, mais ne sait pas ce qu'il cherche : il transmet la requête par onSearch. UserCard (un avatar, un nom et un badge de rôle) se construit de la même façon, tout comme la ligne de statut StatusRow, qui va bientôt rejoindre un panneau plus grand :
1// molecules/StatusRow.jsx
2function StatusRow({ label, value, color }) {
3 return (
4 <div className="status-row">
5 <span className="status-label">{label}</span>
6 <ProgressBar value={value} label={label} />
7 <Badge color={color}>{value}%</Badge>
8 </div>
9 );
10}StatusRow n'ajoute aucun style en dehors de la mise en page : l'aspect de la barre et du badge appartient aux atomes. Quand tu améliores ProgressBar, il s'améliore dans chaque ligne de la station.
Organismes - des sections entières de l'interface
Un organisme est une section qu'on peut poser sur un écran et dont on comprend tout de suite à quoi elle sert. Le panneau SystemsPanel reçoit une liste de systèmes et dessine un StatusRow pour chacun. Il ne décide lui-même que d'une chose : la map statusColors indique quelle couleur de badge correspond à quel statut :
1// organisms/SystemsPanel.jsx
2const statusColors = { ok: 'green', warning: 'orange', critical: 'red' };
3
4function SystemsPanel({ systems }) {
5 return (
6 <section className="systems-panel">
7 <h2>Systèmes du vaisseau</h2>
8 {systems.map(system => (
9 <StatusRow
10 key={system.id}
11 label={system.name}
12 value={system.level}
13 color={statusColors[system.status]}
14 />
15 ))}
16 </section>
17 );
18}La map traduit le langage des données (ok, warning, critical) dans celui des atomes (green, orange, red). Les atomes ne connaissent pas les statuts des systèmes, et les données ne connaissent pas les couleurs. Tu construiras CrewPanel de la même manière : un SearchBar au-dessus d'une liste de cartes UserCard.
Templates et pages
Un template est une mise en page sans contenu. DashboardTemplate dit seulement où se trouvent l'en-tête, le panneau latéral et la partie principale, et reçoit le contenu par des slots, comme les mises en page de la première leçon du module :
1// templates/DashboardTemplate.jsx
2function DashboardTemplate({ header, sidebar, main }) {
3 return (
4 <div className="dashboard">
5 <header className="dash-header">{header}</header>
6 <aside className="dash-sidebar">{sidebar}</aside>
7 <main className="dash-main">{main}</main>
8 </div>
9 );
10}La page MissionDashboard est le dernier niveau : elle prend le template et y place des organismes avec de vraies données. Le hook useFetch récupère les données, l'organisme NavigationBar occupe l'en-tête, et tant que les données se chargent, la page affiche un Spinner :
1// pages/MissionDashboard.jsx
2function MissionDashboard() {
3 const systems = useFetch('/api/systems');
4 const crew = useFetch('/api/crew');
5
6 if (systems.loading || crew.loading) return <Spinner />;
7
8 return (
9 <DashboardTemplate
10 header={<NavigationBar />}
11 sidebar={<CrewPanel crew={crew.data} />}
12 main={<SystemsPanel systems={systems.data} />}
13 />
14 );
15}Dans cette mise en page, seule la page sait d'où viennent les données. Dans le projet final, tu verras qu'un organisme peut aussi faire appel à un hook de données, mais jamais un atome ni une molécule. Le même template avec d'autres organismes donnera un écran de l'équipage, et le même SystemsPanel affichera des données de test dans un catalogue de composants.
Structure des dossiers
Les niveaux de l'Atomic Design se voient le plus facilement dans l'arborescence des fichiers. Chaque niveau reçoit son propre dossier dans src/components, et le nom du dossier dit tout de suite quelle est la taille du composant qu'il contient :
1src/
2 components/
3 atoms/
4 Button.jsx
5 Input.jsx
6 Badge.jsx
7 ProgressBar.jsx
8 molecules/
9 SearchBar.jsx
10 StatusRow.jsx
11 UserCard.jsx
12 organisms/
13 SystemsPanel.jsx
14 CrewPanel.jsx
15 NavigationBar.jsx
16 templates/
17 DashboardTemplate.jsx
18 pages/
19 MissionDashboard.jsxLe chemin d'import reprend cette arborescence. Chaque fichier exporte son composant avec export default, donc tu importes le bouton depuis components/atoms/Button et la ligne de statut depuis components/molecules/StatusRow :
1import Button from 'components/atoms/Button';
2import StatusRow from 'components/molecules/StatusRow';Un tel chemin part du dossier src, donc le projet doit le savoir : dans Vite, tu ajoutes un alias dans resolve.alias, et avec Next.js, tu définis baseUrl ou paths dans le fichier jsconfig.json. Sans cela, tu écris un chemin relatif, par exemple ../atoms/Button.
Le prix de l'ordre
L'Atomic Design n'est pas gratuit. Le compromis principal est le suivant : tu gagnes des composants plus réutilisables, mais tu paies avec davantage de fichiers et une structure de dossiers plus complexe. Un petit formulaire découpé en cinq niveaux, c'est une douzaine de fichiers au lieu d'un seul, et l'équipe doit se mettre d'accord pour savoir si un composant est encore une molécule ou déjà un organisme. Dans une grande application avec des dizaines d'écrans, ce prix est vite rentabilisé : tu corriges le bouton à un seul endroit, une nouvelle personne dans l'équipe sait tout de suite où chercher la ligne de statut, et tu testes les atomes de façon isolée. Dans une application à trois vues, une simple séparation en components/ et pages/ suffit.
Refactoriser un monolithe étape par étape
Tu pars rarement de zéro. Le plus souvent, tu reçois un énorme composant de panneau et tu veux y mettre de l'ordre. Avance de bas en haut, dans cet ordre :
- Identifie les éléments d'UI qui se répètent : les mêmes boutons, barres et badges à de nombreux endroits.
- Extrais-en des atomes, par exemple
Button,InputetBadge. - Combine les atomes en molécules, comme
SearchBar,UserCardouStatusRow. - Construis des organismes à partir des molécules, comme
CrewPaneletSystemsPanel. - Pour finir, crée le template et la page qui disposent les organismes à l'écran.
Après chaque étape, l'application doit avoir le même aspect et fonctionner exactement comme avant. Si quelque chose a changé, tu sais tout de suite quelle étape l'a cassé.
Mon conseil : ne crée pas cinq dossiers vides le premier jour. Commence par les atomes qui se répètent vraiment, et ajoute les niveaux suivants quand le code les réclame de lui-même. Quand tu ne sais pas où ranger un composant, demande-toi s'il a un sens à lui seul : un atome fonctionne partout, une molécule a besoin d'atomes, et un organisme est déjà toute une section de l'écran. Dans la prochaine leçon, tu découvriras le Provider Pattern et les machines à états, utiles quand des organismes commencent à partager un état.
Retiens : tu assembles la station à partir de petites pièces éprouvées au lieu de sculpter chaque écran à partir de zéro.
Code de cette leçon : App.jsx
1import React, { useState } from 'react';
2
3// ============ ATOMES ============
4
5function Button({ variant = 'primary', children, ...props }) {
6 return (
7 <button className={`btn btn-${variant}`} {...props}>
8 {children}
9 </button>
10 );
11}
12
13function Input(props) {
14 return <input className="input" {...props} />;
15}
16
17function Badge({ children, color = 'blue' }) {
18 return <span className={`badge badge-${color}`}>{children}</span>;
19}
20
21function ProgressBar({ value, label }) {
22 return (
23 <div
24 className="progress"
25 role="progressbar"
26 aria-label={label}
27 aria-valuenow={value}
28 aria-valuemin={0}
29 aria-valuemax={100}
30 >
31 <div className="progress-fill" style={{ width: `${value}%` }} />
32 </div>
33 );
34}
35
36function Avatar({ name }) {
37 return <span className="avatar" aria-hidden="true">{name[0]}</span>;
38}
39
40// ============ MOLÉCULES ============
41
42function SearchBar({ onSearch, placeholder = 'Rechercher...' }) {
43 const [query, setQuery] = useState('');
44 return (
45 <div className="search-bar">
46 <Input
47 placeholder={placeholder}
48 aria-label={placeholder}
49 value={query}
50 onChange={(e) => setQuery(e.target.value)}
51 />
52 <Button onClick={() => onSearch(query)}>Rechercher</Button>
53 </div>
54 );
55}
56
57function StatusRow({ label, value, color }) {
58 return (
59 <div className="status-row">
60 <span className="status-label">{label}</span>
61 <ProgressBar value={value} label={label} />
62 <Badge color={color}>{value}%</Badge>
63 </div>
64 );
65}
66
67// Les valeurs des rôles restent des codes, l’équipage voit des libellés en français
68const roleLabels = { captain: 'Capitaine', medic: 'Médecin', engineer: 'Ingénieur', navigator: 'Navigateur' };
69
70function UserCard({ user, onClick }) {
71 return (
72 <button className="user-card" onClick={onClick}>
73 <Avatar name={user.name} />
74 <span className="user-info">
75 <span className="user-name">{user.name}</span>
76 <Badge color={user.role === 'captain' ? 'gold' : 'blue'}>{roleLabels[user.role]}</Badge>
77 </span>
78 </button>
79 );
80}
81
82// ============ ORGANISMES ============
83
84const statusColors = { ok: 'green', warning: 'orange', critical: 'red' };
85
86function SystemsPanel({ systems }) {
87 return (
88 <section className="systems-panel">
89 <h2>Systèmes du vaisseau</h2>
90 {systems.map(system => (
91 <StatusRow
92 key={system.id}
93 label={system.name}
94 value={system.level}
95 color={statusColors[system.status]}
96 />
97 ))}
98 </section>
99 );
100}
101
102function CrewPanel({ crew, onSelect }) {
103 const [query, setQuery] = useState('');
104 // La liste est calculée pendant le rendu, sans copie des props dans l’état
105 const visible = crew.filter(m => m.name.toLowerCase().includes(query.toLowerCase()));
106
107 return (
108 <section className="crew-panel">
109 <h2>Équipage</h2>
110 <SearchBar onSearch={setQuery} placeholder="Chercher dans l’équipage..." />
111 {visible.map(member => (
112 <UserCard key={member.id} user={member} onClick={() => onSelect(member)} />
113 ))}
114 {visible.length === 0 && <p className="empty">Aucun résultat</p>}
115 </section>
116 );
117}
118
119// ============ TEMPLATE : la mise en page seule, sans données ============
120
121function DashboardTemplate({ header, sidebar, main }) {
122 return (
123 <div className="dashboard">
124 <header className="dash-header">{header}</header>
125 <aside className="dash-sidebar">{sidebar}</aside>
126 <main className="dash-main">{main}</main>
127 </div>
128 );
129}
130
131// ============ PAGE : le template rempli de données ============
132
133const crew = [
134 { id: 1, name: 'Commandant Nova', role: 'captain' },
135 { id: 2, name: 'Dr Stellar', role: 'medic' },
136 { id: 3, name: 'Astro', role: 'engineer' },
137 { id: 4, name: 'Ra', role: 'navigator' },
138];
139
140const systemNames = { engines: 'Moteurs', shields: 'Boucliers', lifeSupport: 'Survie', comms: 'Communications' };
141
142const levelToStatus = (level) => (level >= 70 ? 'ok' : level >= 40 ? 'warning' : 'critical');
143
144export default function App() {
145 const [levels, setLevels] = useState({ engines: 95, shields: 42, lifeSupport: 100, comms: 18 });
146 const [selected, setSelected] = useState(null);
147
148 const systems = Object.keys(levels).map(id => ({
149 id,
150 name: systemNames[id],
151 level: levels[id],
152 status: levelToStatus(levels[id]),
153 }));
154
155 const boostShields = () =>
156 setLevels(prev => ({ ...prev, shields: Math.min(prev.shields + 20, 100) }));
157
158 return (
159 <DashboardTemplate
160 header={<h1>Atomic Design en React</h1>}
161 sidebar={<CrewPanel crew={crew} onSelect={setSelected} />}
162 main={
163 <>
164 <SystemsPanel systems={systems} />
165 <Button variant="secondary" onClick={boostShields} disabled={levels.shields === 100}>
166 Recharger les boucliers de 20 %
167 </Button>
168 {selected && (
169 <div className="detail-box">
170 <h3>Sélection : {selected.name}</h3>
171 <p>Rôle : {roleLabels[selected.role]}</p>
172 </div>
173 )}
174 <div className="hierarchy">
175 <h2>Les niveaux de cet écran</h2>
176 <div className="level"><Badge color="blue">Atomes</Badge><span>Button, Input, Badge, ProgressBar, Avatar</span></div>
177 <div className="level"><Badge color="green">Molécules</Badge><span>SearchBar, StatusRow, UserCard</span></div>
178 <div className="level"><Badge color="orange">Organismes</Badge><span>SystemsPanel, CrewPanel</span></div>
179 <div className="level"><Badge color="purple">Template</Badge><span>DashboardTemplate</span></div>
180 <div className="level"><Badge color="gold">Page</Badge><span>Ce composant App</span></div>
181 </div>
182 </>
183 }
184 />
185 );
186}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. À quel niveau de l'Atomic Design se situe un composant SearchBar qui combine Input et Button ?
2. Quel est le principal compromis de l'application de l'Atomic Design dans les grands projets React ?
Tâches pratiques dans le jeu
- Éditeur de code
Construis le panneau des systèmes du vaisseau à partir d'atomes, d'une molécule et d'un organisme. L'atome ProgressBar limite la valeur à l'intervalle 0-100 (la constante percent) : ___BLANK1___ est la valeur de l'attribut aria-valuenow, et ___BLANK2___ est la largeur du remplissage en pourcentage sous forme de chaîne, par exemple '45%'. La molécule StatusRow combine des atomes : ___BLANK3___ est l'atome ProgressBar avec la valeur value et l'étiquette label (un lecteur d'écran y prend le nom de la barre), placé entre le nom et le badge. L'organisme SystemsPanel crée un StatusRow pour chaque système : ___BLANK4___ est la couleur du badge lue dans STATUS_COLORS selon le statut du système (online, degraded, offline).
- Tri vertical
Range les niveaux de l'Atomic Design du plus petit au plus grand :
- Clique dans l'ordre
Clique dans le bon ordre les éléments du chemin d'import de l'atome Button :