Kurs JavaScript i React · Moduł 15: Wzorce i architektura
Projekt końcowy - Architektura kosmicznej stacji
W tej lekcji5
Znasz już kilkanaście wzorców, ale każdy ćwiczyłeś osobno, jak moduły sprawdzane pojedynczo w hangarze. Stacja orbitalna działa dopiero wtedy, gdy moduły są połączone. Czas na podsumowanie wszystkiego, czego nauczyliśmy się w tym module! Twoim zadaniem jest zaprojektowanie i zbudowanie Mission Control Dashboard, czyli panelu kontroli misji kosmicznej, z wykorzystaniem wszystkich poznanych wzorców.
Wymagania projektu
Twój projekt powinien wykorzystywać:
- Composition: wieloslotowy layout (
header,sidebar,main) - Container/Presentational: separacja logiki od UI
- Custom hooks: wyodrębniona logika (np.
useMission,useCrewFilter) - Compound Components: system zakładek lub accordion
- Atomic Design: organizacja komponentów w atomy, molekuły, organizmy
W projekcie dla mentora dojdą co najmniej trzy własne hooki: useToggle, useFetch i useFilter.
Architektura projektu
Zanim napiszesz pierwszą linijkę, rozplanuj foldery. Poniższa struktura układa komponenty według poziomów Atomic Design, a logikę trzyma osobno w hookach:
1MissionDashboard/
2 atoms/
3 StatusBadge - wyświetla status misji (active/completed/failed)
4 ProgressBar - pasek postępu misji
5 IconButton - przycisk z ikoną
6 molecules/
7 MissionCard - karta misji (StatusBadge + tytuł + ProgressBar)
8 CrewMember - członek załogi (avatar + imię + rola)
9 organisms/
10 MissionList - lista misji z filtrowaniem
11 CrewPanel - panel załogi z wyszukiwaniem
12 Tabs/Tab/Panel - compound component z zakładkami
13 hooks/
14 useMission - logika zarządzania misjami
15 useFilter - logika filtrowania
16 useToggle - przełącznik stanówAtomy nie znają danych, molekuły łączą atomy, a dopiero organizmy sięgają po hooki. Jeśli atom importuje hook z danymi, coś poszło nie tak. React nie narzuca struktury folderów: w większych aplikacjach kod często grupuje się według funkcji (features/missions, features/crew), a Atomic Design zostawia dla wspólnej biblioteki komponentów.
Kluczowe wzorce w projekcie
Custom Hook - useMission
Sercem panelu jest hook, który trzyma listę misji i aktualny filtr, a na zewnątrz oddaje przefiltrowane dane oraz akcję zmiany statusu:
1function useMission() {
2 const [missions, setMissions] = useState(initialMissions);
3 const [filter, setFilter] = useState('all');
4
5 const filteredMissions = useMemo(() => {
6 if (filter === 'all') return missions;
7 return missions.filter(m => m.status === filter);
8 }, [missions, filter]);
9
10 const updateStatus = (id, status) => {
11 setMissions(prev =>
12 prev.map(m => m.id === id ? { ...m, status } : m)
13 );
14 };
15
16 return { missions: filteredMissions, filter, setFilter, updateStatus };
17}useMemo liczy listę na nowo tylko wtedy, gdy zmienią się misje albo filtr. updateStatus tworzy nową tablicę przez map i nie modyfikuje oryginału. W zadaniu praktycznym ten hook nazywa się useMissionData, ale logika jest ta sama.
Compound Components - Tabs
Compound components to rodzina komponentów, które działają razem i dzielą ukryty stan, jak <select> i <option> w HTML. Tak wygląda użycie zakładek w panelu:
1function MissionControl() {
2 return (
3 <Tabs defaultTab={0}>
4 <Tabs.TabList>
5 <Tabs.Tab index={0}>Misje</Tabs.Tab>
6 <Tabs.Tab index={1}>Załoga</Tabs.Tab>
7 <Tabs.Tab index={2}>Systemy</Tabs.Tab>
8 </Tabs.TabList>
9
10 <Tabs.Panel index={0}>
11 <MissionList />
12 </Tabs.Panel>
13 <Tabs.Panel index={1}>
14 <CrewPanel />
15 </Tabs.Panel>
16 <Tabs.Panel index={2}>
17 <SystemsOverview />
18 </Tabs.Panel>
19 </Tabs>
20 );
21}Tabs.Tab i Tabs.Panel nie dostają aktywnej zakładki przez propsy, tylko czytają ją ze wspólnego kontekstu, który tworzy Tabs. Szkielet takiego zestawu jest krótki:
1const TabsContext = createContext(null);
2
3function Tabs({ defaultTab = 0, children }) {
4 const [activeTab, setActiveTab] = useState(defaultTab);
5 return (
6 <TabsContext.Provider value={{ activeTab, setActiveTab }}>
7 {children}
8 </TabsContext.Provider>
9 );
10}
11
12Tabs.Tab = function Tab({ index, children }) {
13 const { activeTab, setActiveTab } = useContext(TabsContext);
14 return (
15 <button role="tab" aria-selected={activeTab === index} onClick={() => setActiveTab(index)}>
16 {children}
17 </button>
18 );
19};Atrybut aria-selected działa tylko razem z rolą tab: dzięki nim czytnik ekranu wie, że to zakładka i która jest aktywna. Tabs.TabList to kontener z rolą tablist, a Tabs.Panel renderuje dzieci z rolą tabpanel tylko wtedy, gdy activeTab === index. MissionControl się nie zmienia, bo kontekst to szczegół wewnętrzny zestawu. Tak zbudowane są też zakładki w Radix UI, które dodatkowo łączą zakładkę z panelem atrybutem aria-controls.
Composition - Dashboard Layout
Całość spina układ z trzema slotami, znany z pierwszej lekcji modułu. Nagłówek, panel boczny i główna część dostają gotowe elementy od App:
1function DashboardLayout({ header, sidebar, children }) {
2 return (
3 <div className="dashboard">
4 <header className="dash-header">{header}</header>
5 <div className="dash-body">
6 <aside className="dash-sidebar">{sidebar}</aside>
7 <main className="dash-content">{children}</main>
8 </div>
9 </div>
10 );
11}
12
13function App() {
14 return (
15 <DashboardLayout
16 header={<TopBar title="Panel kontroli misji" />}
17 sidebar={<StatusPanel />}
18 >
19 <MissionControl />
20 </DashboardLayout>
21 );
22}DashboardLayout nie wie nic o misjach ani zakładkach, więc ten sam układ posłuży też dla panelu załogi.
Wskazówki implementacyjne
Krok 1: Custom hooks
Zacznij od wyodrębnienia logiki do custom hooków. Każdy hook powinien mieć jedną odpowiedzialność, jak ten przełącznik:
1// useToggle - prosty przełącznik stanów
2function useToggle(initial = false) {
3 const [value, setValue] = useState(initial);
4 const toggle = useCallback(() => setValue(v => !v), []);
5 return [value, toggle];
6}useToggle zwraca parę jak useState, a useCallback sprawia, że toggle jest tą samą funkcją między renderami. Drugi hook filtruje dowolną listę według wpisanego tekstu:
1// useFilter - logika filtrowania (bez debounce)
2function useFilter(items, filterFn) {
3 const [query, setQuery] = useState('');
4
5 const filteredItems = useMemo(() => {
6 if (!query) return items;
7 return items.filter(item => filterFn(item, query));
8 }, [items, query, filterFn]);
9
10 return { filteredItems, query, setQuery };
11}Ten hook nie ma debounce: filtruje przy każdym znaku, co przy kilkudziesięciu misjach wystarczy. Przy długich listach pomoże wbudowany useDeferredValue, dzięki któremu pisanie pozostaje płynne. filterFn zdefiniuj poza komponentem, bo nowa funkcja przy każdym renderze unieważnia useMemo. Trzeci hook, useFetch(url), napiszesz jak useCrew z lekcji o Container/Presentational: stan data, loading i error oraz useEffect z pobieraniem.
Krok 2: Atomy i molekuły
Zbuduj proste, wielokrotnego użytku komponenty. Atom nie wie nic o misjach, zna tylko status, który ma pokazać, i mapę statusLabels z etykietami dla załogi:
1// Atom - StatusBadge
2const statusLabels = { active: 'Aktywna', completed: 'Ukończona', failed: 'Nieudana' };
3
4function StatusBadge({ status }) {
5 const colors = {
6 active: '#00e676',
7 completed: '#64b5f6',
8 failed: '#f44336',
9 };
10 return (
11 <span style={{ background: colors[status], padding: '4px 12px', borderRadius: '20px' }}>
12 {statusLabels[status]}
13 </span>
14 );
15}Wartości statusów zostają w kodzie po angielsku, bo porównuje je logika, a załoga widzi polskie etykiety. Molekuła łączy atomy w coś, co ma już znaczenie: kartę konkretnej misji z nazwą, statusem i paskiem postępu:
1// Molekuła - MissionCard (łączy atomy)
2function MissionCard({ mission, onStatusChange }) {
3 return (
4 <div className="mission-card">
5 <h3>{mission.name}</h3>
6 <StatusBadge status={mission.status} />
7 <ProgressBar value={mission.progress} />
8 </div>
9 );
10}MissionCard przyjmuje onStatusChange, ale jeszcze go nie używa. Podepniesz go w projekcie, na przykład do przycisku, który oznacza misję jako ukończoną. ProgressBar to drugi atom, który znasz już z lekcji o Atomic Design.
Krok 3: Organizmy z danymi
Połącz molekuły w większe struktury. Organizm jako pierwszy sięga po hook z danymi i rozdaje je kartom:
1// MissionList - organizm z filtrowaniem
2function MissionList() {
3 const { missions, filter, setFilter, updateStatus } = useMission();
4
5 return (
6 <div>
7 <FilterBar value={filter} onChange={setFilter} />
8 {missions.map(m => (
9 <MissionCard key={m.id} mission={m} onStatusChange={updateStatus} />
10 ))}
11 </div>
12 );
13}MissionList pełni tu rolę komponentu kontenerowego: bierze dane z useMission i przekazuje je w dół. FilterBar to kolejna molekuła do napisania.
Krok 4: Złóż w template
Na końcu połącz wszystko w layout z kompozycją. W Atomic Design ten poziom to template: DashboardLayout ze slotami, do których wkładasz organizmy. Wypełniony prawdziwymi danymi staje się stroną (page), piątym i ostatnim poziomem.
Kryteria oceny
- Composition: layout z przynajmniej 3 slotami (header, sidebar, main)
- Container/Presentational: separacja logiki od widoku w co najmniej 2 komponentach
- Custom hooks: minimum 3 własne hooki z reużywalną logiką (
useToggle,useFetch,useFilter) - Compound Components: system zakładek lub accordion ze współdzielonym stanem
- Atomic Design: widoczna organizacja w atomy, molekuły, organizmy
Moja rada: buduj od dołu i uruchamiaj aplikację po każdym kroku. Najpierw hooki z danymi wypisanymi w konsoli, potem atomy, a na końcu cały panel. Powodzenia, pilocie! Pokaż, że potrafisz projektować architekturę React jak prawdziwy inżynier kosmiczny.
Pamiętaj: stacja orbitalna to nie jeden wielki moduł, tylko wiele małych, dobrze połączonych.
Kod do tej lekcji: App.jsx
1import React, { createContext, useContext, useState, useCallback, useMemo } from 'react';
2
3// =============================================
4// CUSTOM HOOKI
5// =============================================
6function useToggle(initial = false) {
7 const [value, setValue] = useState(initial);
8 const toggle = useCallback(() => setValue(p => !p), []);
9 return [value, toggle];
10}
11
12function useMission() {
13 const [missions, setMissions] = useState([
14 { id: 1, name: 'Eksploracja Alfa Centauri', status: 'active', progress: 65 },
15 { id: 2, name: 'Wydobycie w pasie asteroid', status: 'active', progress: 30 },
16 { id: 3, name: 'Ratowanie zespołu Bravo', status: 'completed', progress: 100 },
17 { id: 4, name: 'Skan głębokiego kosmosu', status: 'failed', progress: 12 },
18 { id: 5, name: 'Zaopatrzenie stacji', status: 'active', progress: 80 },
19 ]);
20 const [filter, setFilter] = useState('all');
21
22 const filteredMissions = useMemo(() => {
23 if (filter === 'all') return missions;
24 return missions.filter(m => m.status === filter);
25 }, [missions, filter]);
26
27 const updateStatus = useCallback((id, status) => {
28 setMissions(prev => prev.map(m =>
29 m.id === id ? { ...m, status, progress: status === 'completed' ? 100 : m.progress } : m
30 ));
31 }, []);
32
33 return { missions: filteredMissions, allMissions: missions, filter, setFilter, updateStatus };
34}
35
36// =============================================
37// KOMPONENT ZŁOŻONY (COMPOUND COMPONENT): Tabs
38// =============================================
39const TabsCtx = createContext();
40
41function Tabs({ children, defaultTab = 0 }) {
42 const [active, setActive] = useState(defaultTab);
43 return (
44 <TabsCtx.Provider value={{ active, setActive }}>
45 <div className="tabs">{children}</div>
46 </TabsCtx.Provider>
47 );
48}
49Tabs.TabList = function TabList({ children }) {
50 return <div className="tab-list" role="tablist">{children}</div>;
51};
52Tabs.Tab = function Tab({ index, children }) {
53 const { active, setActive } = useContext(TabsCtx);
54 const selected = active === index;
55 return (
56 <button role="tab" aria-selected={selected} className={`tab ${selected ? 'active' : ''}`} onClick={() => setActive(index)}>
57 {children}
58 </button>
59 );
60};
61Tabs.Panel = function TabPanel({ index, children }) {
62 const { active } = useContext(TabsCtx);
63 return active === index ? <div className="tab-panel" role="tabpanel">{children}</div> : null;
64};
65
66// =============================================
67// ATOMY
68// =============================================
69// Wartości statusów zostają w kodzie, załoga widzi polskie etykiety
70const statusLabels = { active: 'Aktywna', completed: 'Ukończona', failed: 'Nieudana' };
71const filterLabels = { all: 'Wszystkie', active: 'Aktywne', completed: 'Ukończone', failed: 'Nieudane' };
72
73function StatusBadge({ status }) {
74 const colors = { active: 'green', completed: 'blue', failed: 'red' };
75 return <span className={`badge badge-${colors[status] || 'gray'}`}>{statusLabels[status] || status}</span>;
76}
77
78function ProgressBar({ value, label }) {
79 const color = value >= 80 ? '#4caf50' : value >= 40 ? '#ff9800' : '#f44336';
80 return (
81 <div className="progress-bar" role="progressbar" aria-label={label} aria-valuenow={value} aria-valuemin={0} aria-valuemax={100}>
82 <div className="progress-fill" style={{ width: `${value}%`, background: color }} />
83 </div>
84 );
85}
86
87// =============================================
88// MOLEKUŁY
89// =============================================
90function MissionCard({ mission, onComplete, onCancel }) {
91 return (
92 <div className="mission-card">
93 <div className="mc-header">
94 <h4>{mission.name}</h4>
95 <StatusBadge status={mission.status} />
96 </div>
97 <ProgressBar value={mission.progress} label={mission.name} />
98 {mission.status === 'active' && (
99 <div className="mc-actions">
100 <button className="btn-sm btn-success" onClick={() => onComplete(mission.id)}>
101 Ukończ
102 </button>
103 <button className="btn-sm btn-danger" onClick={() => onCancel(mission.id)}>
104 Anuluj
105 </button>
106 </div>
107 )}
108 </div>
109 );
110}
111
112function CrewMember({ name, role }) {
113 return (
114 <div className="crew-member">
115 <div className="cm-avatar">{name[0]}</div>
116 <div>
117 <div className="cm-name">{name}</div>
118 <div className="cm-role">{role}</div>
119 </div>
120 </div>
121 );
122}
123
124// =============================================
125// ORGANIZMY
126// =============================================
127function MissionList({ missions, onComplete, onCancel }) {
128 return (
129 <div className="mission-list">
130 {missions.length === 0 && <p className="empty-msg">Żadna misja nie pasuje do filtra</p>}
131 {missions.map(m => (
132 <MissionCard key={m.id} mission={m} onComplete={onComplete} onCancel={onCancel} />
133 ))}
134 </div>
135 );
136}
137
138function CrewPanel() {
139 const crew = [
140 { name: 'Komandor Nova', role: 'Kapitan' },
141 { name: 'Dr Stellar', role: 'Oficer medyczny' },
142 { name: 'Astro', role: 'Inżynier' },
143 { name: 'Ra', role: 'Nawigator' },
144 ];
145 return (
146 <div className="crew-panel">
147 {crew.map(c => <CrewMember key={c.name} name={c.name} role={c.role} />)}
148 </div>
149 );
150}
151
152function SystemsOverview() {
153 const systems = [
154 { name: 'Silniki', value: 95 },
155 { name: 'Osłony', value: 72 },
156 { name: 'Podtrzymanie życia', value: 100 },
157 { name: 'Komunikacja', value: 88 },
158 ];
159 return (
160 <div className="systems-overview">
161 {systems.map(s => (
162 <div key={s.name} className="sys-row">
163 <span>{s.name}</span>
164 <ProgressBar value={s.value} label={s.name} />
165 <span className="sys-pct">{s.value}%</span>
166 </div>
167 ))}
168 </div>
169 );
170}
171
172// =============================================
173// TEMPLATE (kompozycja)
174// =============================================
175function DashboardLayout({ header, sidebar, children }) {
176 return (
177 <div className="dashboard-layout">
178 <header className="dl-header">{header}</header>
179 <div className="dl-body">
180 {sidebar && <aside className="dl-sidebar">{sidebar}</aside>}
181 <main className="dl-main">{children}</main>
182 </div>
183 </div>
184 );
185}
186
187// =============================================
188// PAGE (strona)
189// =============================================
190export default function App() {
191 const { missions, allMissions, filter, setFilter, updateStatus } = useMission();
192 const [showFilters, toggleFilters] = useToggle(true);
193
194 const filters = ['all', 'active', 'completed', 'failed'];
195
196 // Statystyki liczymy z pełnej listy, żeby filtr nie zmieniał sum w nagłówku
197 const stats = useMemo(() => ({
198 total: allMissions.length,
199 active: allMissions.filter(m => m.status === 'active').length,
200 done: allMissions.filter(m => m.status === 'completed').length,
201 }), [allMissions]);
202
203 return (
204 <DashboardLayout
205 header={
206 <div className="header-bar">
207 <h1>Panel kontroli misji</h1>
208 <div className="header-stats">
209 <span>Razem: {stats.total}</span>
210 <span>Aktywne: {stats.active}</span>
211 <span>Ukończone: {stats.done}</span>
212 <button className="toggle-btn" onClick={toggleFilters}>
213 {showFilters ? 'Ukryj filtry' : 'Pokaż filtry'}
214 </button>
215 </div>
216 </div>
217 }
218 sidebar={showFilters && (
219 <div>
220 <h3 className="sidebar-title">Filtr misji</h3>
221 <div className="filter-group">
222 {filters.map(f => (
223 <button
224 key={f}
225 className={`filter-btn ${filter === f ? 'active' : ''}`}
226 aria-pressed={filter === f}
227 onClick={() => setFilter(f)}
228 >
229 {filterLabels[f]}
230 </button>
231 ))}
232 </div>
233 </div>
234 )}
235 >
236 <Tabs defaultTab={0}>
237 <Tabs.TabList>
238 <Tabs.Tab index={0}>Misje</Tabs.Tab>
239 <Tabs.Tab index={1}>Załoga</Tabs.Tab>
240 <Tabs.Tab index={2}>Systemy</Tabs.Tab>
241 </Tabs.TabList>
242 <Tabs.Panel index={0}>
243 <MissionList
244 missions={missions}
245 onComplete={(id) => updateStatus(id, 'completed')}
246 onCancel={(id) => updateStatus(id, 'failed')}
247 />
248 </Tabs.Panel>
249 <Tabs.Panel index={1}>
250 <CrewPanel />
251 </Tabs.Panel>
252 <Tabs.Panel index={2}>
253 <SystemsOverview />
254 </Tabs.Panel>
255 </Tabs>
256 </DashboardLayout>
257 );
258}Widzisz błąd w tej lekcji?
Zadania praktyczne w grze
- Edytor kodu
Połącz wzorce z modułu w jednym panelu. Hook useMissionData: ___BLANK1___ to lista misji o statusie równym filtrowi (gdy filtr to 'all', hook zwraca wszystkie misje). Hook useToggle: ___BLANK2___ to funkcja aktualizująca, która odwraca poprzednią wartość (toggle jest w useCallback z pustą tablicą, więc nie może czytać value z pierwszego renderu). Compound component Tabs: ___BLANK3___ to obiekt z activeTab i setActiveTab, który Tabs podaje przez TabsContext, a ___BLANK4___ ustawia aria-selected na true tylko dla aktywnej zakładki. Atomy, molekuła, organizm i DashboardLayout ze slotami są gotowe.