Kurs JavaScript i React · Moduł 15: Wzorce i architektura
Atomic Design w React - Budowanie stacji z atomów
W tej lekcji8
Stacja orbitalna rozrosła się do dwudziestu ekranów. Na każdym jest przycisk, pasek postępu i plakietka statusu, ale każdy zespół napisał je po swojemu: tu pasek ma sześć pikseli wysokości, tam osiem, a czerwony alarm ma trzy różne odcienie. Gdy Komandor Nova prosi, żeby wszystkie plakietki krytyczne wyglądały tak samo, szukasz ich w czterdziestu plikach. Problemem nie jest żaden pojedynczy komponent, tylko brak planu, który mówi, z jakich części składa się interfejs. Taki plan daje Atomic Design.
Pięć poziomów Atomic Design
Atomic Design to sposób myślenia o interfejsie, który Brad Frost opisał w 2013 roku, a potem rozwinął w książce "Atomic Design". Nazwy zapożycza z przyrody: atomy łączą się w molekuły, a molekuły w organizmy. Na stacji wygląda to tak:
- Atomy: najmniejsze części, których nie ma sensu dzielić dalej, jak śruba, lampka czy przełącznik. W React to
Button,Input,BadgeczyProgressBar. - Molekuły: kilka atomów, które razem pełnią jedną funkcję, jak przełącznik z lampką i podpisem. Na przykład
SearchBartoInputiButton. - Organizmy: samodzielne sekcje interfejsu złożone z molekuł i atomów, jak cały panel systemów na mostku.
- Szablony (templates): układ strony ze slotami, ale bez prawdziwych danych, jak plan pokładu, na którym zaznaczono, gdzie stanie który panel.
- Strony (pages): szablon wypełniony prawdziwymi danymi, czyli pokład w dniu misji.
Kolejność poziomów jest zawsze ta sama, od najmniejszego do największego: atomy, molekuły, organizmy, szablony, strony. Frost podkreśla jednak, że to model, a nie proces krok po kroku: nad kilkoma poziomami możesz pracować jednocześnie.
Atomy - najmniejsze części
Atom nie wie, gdzie zostanie użyty. Dostaje dane przez propsy i zawsze wygląda tak samo. Najprostszy jest Badge, czyli kolorowa plakietka z krótkim tekstem:
1// atoms/Badge.jsx
2function Badge({ children, color = 'blue' }) {
3 return <span className={`badge badge-${color}`}>{children}</span>;
4}Kolor przychodzi z zewnątrz jako nazwa wariantu, a nie jako kod koloru, więc sam odcień ustalasz w jednym miejscu: w stylach klasy badge-red. Gdy alarm ma zmienić odcień, poprawiasz jedną linię CSS, a nie czterdzieści plików.
Ciekawszy jest ProgressBar. Wizualnie to dwa prostokąty, ale czytnik ekranu musi wiedzieć, że to pasek postępu, czego dotyczy i jaką ma wartość. Dlatego pasek dostaje rolę progressbar, opis w aria-label i trzy atrybuty z liczbami:
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 podaje bieżącą wartość, a aria-valuemin i aria-valuemax jej zakres. Szerokość wypełnienia to ta sama liczba zapisana w procentach, więc wygląd i opis dla czytnika nigdy się nie rozjadą. Przeglądarka ma też gotowy element <progress>. Własny pasek z rolą progressbar budujemy wtedy, gdy potrzebujemy pełnej kontroli nad wyglądem, i wtedy sami musimy go opisać.
Molekuły - atomy z jednym zadaniem
Molekuła łączy kilka atomów w coś, co ma już znaczenie. Klasyczny przykład to wyszukiwarka: atom Input, czyli pole tekstowe w stylu stacji, i atom Button razem służą do jednego, do szukania:
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)}>Szukaj</Button>
9 </div>
10 );
11}SearchBar pamięta wpisywany tekst, ale nie wie, czego szuka: zapytanie oddaje przez onSearch. Tak samo powstaje UserCard (avatar, imię i plakietka z rolą) albo wiersz statusu StatusRow, który za chwilę złoży się w większy panel:
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 nie dodaje żadnego stylu poza układem: wygląd paska i plakietki należy do atomów. Gdy poprawisz ProgressBar, poprawi się w każdym wierszu na stacji.
Organizmy - całe sekcje interfejsu
Organizm to sekcja, którą można postawić na ekranie i od razu zrozumieć, do czego służy. Panel SystemsPanel dostaje listę systemów i dla każdego rysuje jeden StatusRow. Sam decyduje tylko o jednym: mapa statusColors mówi, jaki kolor plakietki odpowiada jakiemu statusowi:
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>Systemy statku</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}Mapa tłumaczy język danych (ok, warning, critical) na język atomów (green, orange, red). Atomy nie znają statusów systemów, a dane nie znają kolorów. Tak samo zbudujesz CrewPanel: SearchBar nad listą kart UserCard.
Szablony i strony
Szablon to układ bez treści. DashboardTemplate mówi tylko, gdzie stoi nagłówek, panel boczny i część główna, a zawartość dostaje przez sloty, jak układy z pierwszej lekcji modułu:
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}Strona MissionDashboard to ostatni poziom: bierze szablon i wkłada w niego organizmy z prawdziwymi danymi. Dane pobiera hook useFetch, w nagłówku stoi organizm NavigationBar, a dopóki dane się ładują, strona pokazuje 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}W tym układzie tylko strona wie, skąd pochodzą dane. W projekcie końcowym zobaczysz, że po hook z danymi może sięgnąć też organizm, ale nigdy atom ani molekuła. Ten sam szablon z innymi organizmami da ekran załogi, a ten sam SystemsPanel pokaże dane testowe w katalogu komponentów.
Struktura folderów
Poziomy Atomic Design najłatwiej zobaczyć w drzewie plików. Każdy poziom dostaje własny folder w src/components, a nazwa folderu od razu mówi, jak duży jest komponent w środku:
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.jsxŚcieżka importu powtarza to drzewo. Każdy plik eksportuje swój komponent przez export default, więc przycisk importujesz z components/atoms/Button, a wiersz statusu z components/molecules/StatusRow:
1import Button from 'components/atoms/Button';
2import StatusRow from 'components/molecules/StatusRow';Taka ścieżka jest liczona od folderu src, więc projekt musi o tym wiedzieć: w Vite dodajesz alias w resolve.alias, a w Next.js ustawiasz baseUrl albo paths w pliku jsconfig.json. Bez tego piszesz ścieżkę względną, na przykład ../atoms/Button.
Cena porządku
Atomic Design nie jest za darmo. Główny kompromis brzmi tak: zyskujesz większą reużywalność komponentów, ale płacisz większą liczbą plików i bardziej złożoną strukturą folderów. Mały formularz rozbity na pięć poziomów to kilkanaście plików zamiast jednego, a zespół musi się umówić, czy dany komponent to jeszcze molekuła, czy już organizm. W dużej aplikacji z dziesiątkami ekranów ta cena szybko się zwraca: przycisk poprawiasz w jednym miejscu, nowa osoba w zespole od razu wie, gdzie szukać wiersza statusu, a atomy testujesz w izolacji. W aplikacji z trzema widokami wystarczy prosty podział na components/ i pages/.
Refaktoryzacja monolitu krok po kroku
Rzadko zaczynasz od zera. Częściej dostajesz jeden ogromny komponent panelu i chcesz go uporządkować. Idź od dołu, w tej kolejności:
- Zidentyfikuj powtarzające się elementy UI: te same przyciski, paski i plakietki w wielu miejscach.
- Wyodrębnij z nich atomy, na przykład
Button,InputiBadge. - Połącz atomy w molekuły, takie jak
SearchBar,UserCardczyStatusRow. - Zbuduj z molekuł organizmy, takie jak
CrewPaneliSystemsPanel. - Na końcu stwórz szablon i stronę, które ułożą organizmy na ekranie.
Po każdym kroku aplikacja powinna wyglądać i działać tak samo jak przed nim. Jeśli coś się zmieniło, od razu wiesz, który krok to zepsuł.
Moja rada: nie zakładaj pięciu pustych folderów pierwszego dnia. Zacznij od atomów, które naprawdę się powtarzają, a kolejne poziomy dokładaj, gdy kod sam o nie poprosi. Gdy nie wiesz, gdzie odłożyć komponent, zapytaj, czy ma sens sam w sobie: atom działa wszędzie, molekuła potrzebuje atomów, a organizm to już cała sekcja ekranu. W następnej lekcji poznasz Provider Pattern i maszyny stanów, które przydadzą się, gdy organizmy zaczną dzielić wspólny stan.
Pamiętaj: stację składasz z małych, sprawdzonych części, a nie rzeźbisz każdego ekranu od nowa.
Kod do tej lekcji: App.jsx
1import React, { useState } from 'react';
2
3// ============ ATOMY ============
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// ============ MOLEKUŁY ============
41
42function SearchBar({ onSearch, placeholder = 'Szukaj...' }) {
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)}>Szukaj</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// Wartości ról zostają w kodzie po angielsku, załoga widzi polskie etykiety
68const roleLabels = { captain: 'Kapitan', medic: 'Medyk', engineer: 'Inżynier', navigator: 'Nawigator' };
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// ============ ORGANIZMY ============
83
84const statusColors = { ok: 'green', warning: 'orange', critical: 'red' };
85
86function SystemsPanel({ systems }) {
87 return (
88 <section className="systems-panel">
89 <h2>Systemy statku</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 // Lista wyliczana podczas renderowania, bez kopii propsów w stanie
105 const visible = crew.filter(m => m.name.toLowerCase().includes(query.toLowerCase()));
106
107 return (
108 <section className="crew-panel">
109 <h2>Załoga</h2>
110 <SearchBar onSearch={setQuery} placeholder="Szukaj w załodze..." />
111 {visible.map(member => (
112 <UserCard key={member.id} user={member} onClick={() => onSelect(member)} />
113 ))}
114 {visible.length === 0 && <p className="empty">Brak wyników</p>}
115 </section>
116 );
117}
118
119// ============ SZABLON (TEMPLATE): sam układ, bez danych ============
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// ============ STRONA (PAGE): szablon wypełniony danymi ============
132
133const crew = [
134 { id: 1, name: 'Komandor 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: 'Silniki', shields: 'Osłony', lifeSupport: 'Podtrzymanie życia', comms: 'Łączność' };
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 w 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 Doładuj osłony o 20%
167 </Button>
168 {selected && (
169 <div className="detail-box">
170 <h3>Wybrano: {selected.name}</h3>
171 <p>Rola: {roleLabels[selected.role]}</p>
172 </div>
173 )}
174 <div className="hierarchy">
175 <h2>Poziomy na tym ekranie</h2>
176 <div className="level"><Badge color="blue">Atomy</Badge><span>Button, Input, Badge, ProgressBar, Avatar</span></div>
177 <div className="level"><Badge color="green">Molekuły</Badge><span>SearchBar, StatusRow, UserCard</span></div>
178 <div className="level"><Badge color="orange">Organizmy</Badge><span>SystemsPanel, CrewPanel</span></div>
179 <div className="level"><Badge color="purple">Szablon</Badge><span>DashboardTemplate</span></div>
180 <div className="level"><Badge color="gold">Strona</Badge><span>Ten komponent App</span></div>
181 </div>
182 </>
183 }
184 />
185 );
186}Widzisz błąd w tej lekcji?
Sprawdź się
Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.
1. Na którym poziomie Atomic Design znajduje się komponent SearchBar łączący Input i Button?
2. Jaki jest główny kompromis stosowania Atomic Design w dużych projektach React?
Zadania praktyczne w grze
- Edytor kodu
Zbuduj panel systemów statku z atomów, molekuły i organizmu. Atom ProgressBar ogranicza wartość do zakresu 0-100 (stała percent): ___BLANK1___ to wartość atrybutu aria-valuenow, a ___BLANK2___ to szerokość wypełnienia w procentach jako napis, na przykład '45%'. Molekuła StatusRow łączy atomy: ___BLANK3___ to atom ProgressBar z wartością value i etykietą label (z niej czytnik ekranu bierze nazwę paska), wstawiony między nazwą a odznaką. Organizm SystemsPanel tworzy StatusRow dla każdego systemu: ___BLANK4___ to kolor odznaki odczytany z STATUS_COLORS według statusu systemu (online, degraded, offline).
- Układanie w pionie
Ułóż poziomy Atomic Design od najmniejszego do największego:
- Klikanie w kolejności
Kliknij elementy ścieżki importu atomu Button w prawidłowej kolejności: