Kurs JavaScript i React · Moduł 15: Wzorce i architektura

Atomic Design w React - Budowanie stacji z atomów

7 min czytania
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:

  1. Atomy: najmniejsze części, których nie ma sensu dzielić dalej, jak śruba, lampka czy przełącznik. W React to Button, Input, Badge czy ProgressBar.
  2. Molekuły: kilka atomów, które razem pełnią jedną funkcję, jak przełącznik z lampką i podpisem. Na przykład SearchBar to Input i Button.
  3. Organizmy: samodzielne sekcje interfejsu złożone z molekuł i atomów, jak cały panel systemów na mostku.
  4. Szablony (templates): układ strony ze slotami, ale bez prawdziwych danych, jak plan pokładu, na którym zaznaczono, gdzie stanie który panel.
  5. 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:

  1. Zidentyfikuj powtarzające się elementy UI: te same przyciski, paski i plakietki w wielu miejscach.
  2. Wyodrębnij z nich atomy, na przykład Button, Input i Badge.
  3. Połącz atomy w molekuły, takie jak SearchBar, UserCard czy StatusRow.
  4. Zbuduj z molekuł organizmy, takie jak CrewPanel i SystemsPanel.
  5. 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. 1. Na którym poziomie Atomic Design znajduje się komponent SearchBar łączący Input i Button?

  2. 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:

Przydatne artykuły