Kurs JavaScript i React · Moduł 10: Ekosystem i przyszłość React
Projekt główny: Kosmiczne Centrum Dowodzenia Misją
W tej lekcji6
Gratulacje, astronauto! Dotarłeś do finałowej misji w module React Mastery. Czas połączyć wszystkie poznane technologie w jeden, potężny system - Kosmiczne Centrum Dowodzenia Misją. Ten projekt to twój ostateczny test, który sprawdzi, czy opanowałeś zaawansowane wzorce React.
Cel projektu
Zbudujesz dashboard do zarządzania kosmiczną misją, który łączy kluczowe koncepty z całego modułu:
1. React Portals - Modalne okna alertów
Użyj React Portals do renderowania modalnych okien alertów misji poza głównym drzewem DOM. Alerty powinny pojawiać się nad interfejsem z efektem overlay.
2. Internacjonalizacja (i18n)
Dodaj przełącznik języka (PL/EN) do nagłówka. Cały dashboard powinien zmieniać język interfejsu dynamicznie za pomocą Context API i obiektów z tłumaczeniami.
3. State Management z useReducer
Zarządzaj stanem misji (status, paliwo, załoga, systemy) za pomocą useReducer. Zdefiniuj akcje takie jak LAUNCH_MISSION, UPDATE_FUEL, TOGGLE_SYSTEM, ADD_CREW_MEMBER.
4. Compound Components
Stwórz komponent <Dashboard.Panel> we wzorcu Compound Components, który pozwala elastycznie komponować sekcje dashboardu:
1<Dashboard>
2 <Dashboard.Header title="Mission Control" />
3 <Dashboard.Panel title="Systems">
4 <SystemsList />
5 </Dashboard.Panel>
6 <Dashboard.Panel title="Crew">
7 <CrewList />
8 </Dashboard.Panel>
9</Dashboard>5. Error Boundaries
Opakuj sekcje dashboardu w Error Boundaries, aby awaria jednego panelu nie powodowała crashu całej aplikacji. Wyświetl przyjazny komunikat błędu z opcją ponownego załadowania sekcji.
6. Performance Optimization
Zastosuj React.memo na komponentach listy, useMemo na obliczeniach statystyk misji i useCallback na handlerach zdarzeń przekazywanych do komponentów potomnych.
Struktura aplikacji
Zanim napiszesz pierwszą linię kodu, rozpisz drzewo komponentów. Dostawcy kontekstu leżą na samej górze, żeby każdy panel mógł czytać język i stan misji, a każdy panel ma własny Error Boundary, więc awaria jednego nie wyłączy pozostałych. Okno alarmu renderuje się przez portal, poza drzewem dashboardu:
1App
2├── LanguageProvider (Context - i18n)
3├── MissionProvider (useReducer - state)
4├── Dashboard (Compound Component)
5│ ├── Dashboard.Header
6│ ├── Dashboard.Panel "Status Misji"
7│ │ └── ErrorBoundary
8│ │ └── MissionStatus (React.memo)
9│ ├── Dashboard.Panel "Systemy"
10│ │ └── ErrorBoundary
11│ │ └── SystemsList (React.memo)
12│ └── Dashboard.Panel "Załoga"
13│ └── ErrorBoundary
14│ └── CrewList (React.memo)
15└── Portal → AlertModal (React Portal)Wymagania
- Dashboard wyświetla 3 panele: Status Misji, Systemy, Załoga
- Przełącznik języka (PL/EN) zmienia wszystkie etykiety interfejsu
- Przycisk startu misji zmienia status i zużywa paliwo (useReducer)
- Systemy można włączać/wyłączać (toggle)
- Alert modal (Portal) pojawia się gdy paliwo spadnie poniżej 20%
- Error Boundary wyświetla fallback gdy komponent rzuci błąd
- Memoizacja zapobiega zbędnym re-renderom
Wskazówki implementacyjne
Reducer dla stanu misji
Cały stan misji trzyma jeden reducer, czyli czysta funkcja, która z poprzedniego stanu i akcji wylicza nowy stan. Nie zmienia obiektu state, tylko zwraca kopię z poprawionymi polami, dzięki czemu React wie, że coś się zmieniło, a React.memo może pominąć panele, których dane się nie zmieniły:
1const initialState = {
2 status: 'ready',
3 fuel: 100,
4 systems: [
5 { id: 1, name: 'Nawigacja', active: true },
6 { id: 2, name: 'Podtrzymywanie życia', active: true },
7 { id: 3, name: 'Komunikacja', active: false },
8 ],
9 crew: ['Kowalski', 'Nowak', 'Wiśniewska'],
10};
11
12function missionReducer(state, action) {
13 switch (action.type) {
14 case 'LAUNCH_MISSION':
15 return { ...state, status: 'inProgress', fuel: Math.max(0, state.fuel - 20) };
16 case 'UPDATE_FUEL':
17 return { ...state, fuel: Math.max(0, state.fuel + action.payload) };
18 case 'TOGGLE_SYSTEM':
19 return {
20 ...state,
21 systems: state.systems.map(s =>
22 s.id === action.payload ? { ...s, active: !s.active } : s
23 ),
24 };
25 case 'ADD_CREW_MEMBER':
26 return { ...state, crew: [...state.crew, action.payload] };
27 default:
28 return state;
29 }
30}Komponenty nie zmieniają stanu same, tylko wysyłają akcje, na przykład dispatch({ type: 'TOGGLE_SYSTEM', payload: 2 }). Math.max pilnuje, żeby paliwo nigdy nie spadło poniżej zera.
Context dla i18n
Tłumaczenia to zwykłe obiekty, po jednym na język. Dostawca kontekstu trzyma bieżący język w stanie i udostępnia funkcję setLang, a komponenty czytają teksty przez własny hook useLanguage, więc zmiana języka od razu przerysowuje wszystkie etykiety:
1const translations = {
2 pl: { launch: 'Rozpocznij misję', fuel: 'Paliwo', systems: 'Systemy' },
3 en: { launch: 'Launch Mission', fuel: 'Fuel', systems: 'Systems' },
4};
5
6const LanguageContext = createContext();
7
8function LanguageProvider({ children }) {
9 const [lang, setLang] = useState('pl');
10 const t = translations[lang];
11 return (
12 <LanguageContext.Provider value={{ lang, setLang, t }}>
13 {children}
14 </LanguageContext.Provider>
15 );
16}
17
18function useLanguage() {
19 return useContext(LanguageContext);
20}Portal dla alertów
Alarm musi przykryć cały interfejs, a rodzic z overflow: hidden albo własnym z-index potrafi go przyciąć. createPortal renderuje okno w innym węźle DOM, ale zdarzenia i kontekst nadal płyną przez drzewo Reacta, więc przycisk w oknie może wywołać dispatch albo przeczytać tłumaczenia:
1function AlertModal({ message, onClose }) {
2 return createPortal(
3 <div className="modal-overlay">
4 <div className="modal-content">
5 <h2>Uwaga!</h2>
6 <p>{message}</p>
7 <button onClick={onClose}>Zamknij</button>
8 </div>
9 </div>,
10 document.getElementById('modal-root')
11 );
12}Węzeł #modal-root dodajesz w index.html obok #root. W edytorze poniżej portal trafia po prostu do document.body.
Plan pracy
Najłatwiej budować projekt warstwami i sprawdzać działanie po każdym kroku:
- Zacznij od
MissionProviderz reducerem i jednego panelu, który pokazuje status i paliwo. - Dodaj
LanguageProvideri przepuść wszystkie etykiety przez funkcję tłumaczącą. - Zbuduj
DashboardzDashboard.HeaderiDashboard.Panel, a w każdym panelu umieśćErrorBoundary. - Dopisz alarm w portalu, który pojawia się, gdy paliwo spadnie poniżej 20%.
- Na końcu zmierz rendery w React DevTools i dodaj
React.memo,useMemoorazuseCallbacktam, gdzie naprawdę skracają pracę.
Kryteria oceny
- Wszystkie 6 wzorców (Portals, i18n, useReducer, Compound Components, Error Boundaries, memoizacja) muszą być zaimplementowane
- Aplikacja powinna działać bez błędów w konsoli
- Przełączanie języka powinno aktualizować wszystkie etykiety
- Alert powinien pojawiać się automatycznie przy niskim paliwie
Edytor poniżej zawiera działający szkielet centrum dowodzenia ze wszystkimi sześcioma wzorcami. Przełącz język, rozpocznij misję, spal paliwo poniżej 20% i zasymuluj awarię panelu systemów, żeby zobaczyć każdy wzorzec w akcji. Potem rozbuduj aplikację o własne pomysły, na przykład akcję kończącą misję, statystyki floty liczone w useMemo albo trzeci język, i porównaj ją ze strukturą opisaną powyżej.
Kod do tej lekcji: App.jsx
1import { Component, createContext, memo, useCallback, useContext, useMemo, useReducer, useState } from 'react';
2import { createPortal } from 'react-dom';
3import './App.css';
4
5// === I18N: Context z tłumaczeniami ===
6const translations = {
7 pl: {
8 title: 'Centrum Dowodzenia Misją',
9 status: 'Status misji',
10 systems: 'Systemy',
11 crew: 'Załoga',
12 fuel: 'Paliwo',
13 launch: 'Rozpocznij misję',
14 abort: 'Przerwij misję',
15 burnFuel: '-10% paliwa',
16 ready: 'Gotowi do startu',
17 inProgress: 'Misja w toku',
18 lowFuel: 'Uwaga! Niski poziom paliwa!',
19 alertTitle: 'Alarm',
20 dismiss: 'Zamknij',
21 addMember: 'Dodaj członka',
22 namePlaceholder: 'Imię astronauty',
23 active: 'aktywnych',
24 on: 'WŁ.',
25 off: 'WYŁ.',
26 engine: 'Silnik',
27 shields: 'Tarcze',
28 comms: 'Komunikacja',
29 navigation: 'Nawigacja',
30 commander: 'Dowódca',
31 navigator: 'Nawigator',
32 recruit: 'Rekrut',
33 crashed: 'Panel uległ awarii',
34 reload: 'Załaduj ponownie',
35 simulateCrash: 'Symuluj awarię panelu',
36 },
37 en: {
38 title: 'Mission Command Center',
39 status: 'Mission status',
40 systems: 'Systems',
41 crew: 'Crew',
42 fuel: 'Fuel',
43 launch: 'Launch mission',
44 abort: 'Abort mission',
45 burnFuel: '-10% fuel',
46 ready: 'Ready for launch',
47 inProgress: 'Mission in progress',
48 lowFuel: 'Warning! Low fuel level!',
49 alertTitle: 'Alert',
50 dismiss: 'Dismiss',
51 addMember: 'Add member',
52 namePlaceholder: 'Astronaut name',
53 active: 'active',
54 on: 'ON',
55 off: 'OFF',
56 engine: 'Engine',
57 shields: 'Shields',
58 comms: 'Communications',
59 navigation: 'Navigation',
60 commander: 'Commander',
61 navigator: 'Navigator',
62 recruit: 'Recruit',
63 crashed: 'The panel has crashed',
64 reload: 'Reload',
65 simulateCrash: 'Simulate a panel failure',
66 },
67};
68
69const FIRST_LANG = 'pl';
70const SECOND_LANG = 'en';
71
72const LanguageContext = createContext(null);
73
74function LanguageProvider({ children }) {
75 const [lang, setLang] = useState(FIRST_LANG);
76 const t = useCallback(key => translations[lang][key] ?? key, [lang]);
77 const toggleLang = useCallback(() => setLang(l => (l === FIRST_LANG ? SECOND_LANG : FIRST_LANG)), []);
78 const value = useMemo(() => ({ lang, t, toggleLang }), [lang, t, toggleLang]);
79
80 return <LanguageContext.Provider value={value}>{children}</LanguageContext.Provider>;
81}
82
83function useLanguage() {
84 return useContext(LanguageContext);
85}
86
87// === STAN MISJI: useReducer ===
88const initialMission = {
89 status: 'ready',
90 fuel: 100,
91 systems: [
92 { id: 1, key: 'engine', active: true },
93 { id: 2, key: 'shields', active: false },
94 { id: 3, key: 'comms', active: true },
95 { id: 4, key: 'navigation', active: true },
96 ],
97 crew: [
98 { id: 1, name: 'Ra', role: 'commander' },
99 { id: 2, name: 'Astro', role: 'navigator' },
100 ],
101};
102
103function missionReducer(state, action) {
104 switch (action.type) {
105 case 'LAUNCH_MISSION':
106 return { ...state, status: 'inProgress', fuel: Math.max(0, state.fuel - 15) };
107 case 'ABORT_MISSION':
108 return { ...state, status: 'ready' };
109 case 'UPDATE_FUEL':
110 return { ...state, fuel: Math.max(0, state.fuel + action.payload) };
111 case 'TOGGLE_SYSTEM':
112 return {
113 ...state,
114 systems: state.systems.map(s => (s.id === action.payload ? { ...s, active: !s.active } : s)),
115 };
116 case 'ADD_CREW_MEMBER':
117 return { ...state, crew: [...state.crew, { ...action.payload, role: 'recruit' }] };
118 default:
119 return state;
120 }
121}
122
123const MissionContext = createContext(null);
124
125function MissionProvider({ children }) {
126 const [state, dispatch] = useReducer(missionReducer, initialMission);
127 const value = useMemo(() => ({ state, dispatch }), [state]);
128 return <MissionContext.Provider value={value}>{children}</MissionContext.Provider>;
129}
130
131function useMission() {
132 return useContext(MissionContext);
133}
134
135// === ERROR BOUNDARY: awaria jednego panelu nie wyłącza reszty ===
136class ErrorBoundary extends Component {
137 constructor(props) {
138 super(props);
139 this.state = { hasError: false };
140 }
141
142 static getDerivedStateFromError() {
143 return { hasError: true };
144 }
145
146 render() {
147 if (this.state.hasError) {
148 return (
149 <div className="error-fallback">
150 <p>{this.props.message}</p>
151 <button onClick={() => this.setState({ hasError: false })}>{this.props.retryLabel}</button>
152 </div>
153 );
154 }
155 return this.props.children;
156 }
157}
158
159// === COMPOUND COMPONENT: Dashboard, Dashboard.Header, Dashboard.Panel ===
160function Dashboard({ children }) {
161 return <div className="dashboard">{children}</div>;
162}
163
164Dashboard.Header = memo(function DashboardHeader() {
165 const { t, lang, toggleLang } = useLanguage();
166 const next = lang === FIRST_LANG ? SECOND_LANG : FIRST_LANG;
167
168 return (
169 <header className="dashboard-header">
170 <h1>{t('title')}</h1>
171 <button className="lang-btn" onClick={toggleLang}>{next.toUpperCase()}</button>
172 </header>
173 );
174});
175
176Dashboard.Panel = memo(function DashboardPanel({ title, children }) {
177 const { t } = useLanguage();
178
179 return (
180 <section className="dashboard-panel">
181 <h2>{title}</h2>
182 <ErrorBoundary message={t('crashed')} retryLabel={t('reload')}>
183 {children}
184 </ErrorBoundary>
185 </section>
186 );
187});
188
189// === STATUS MISJI ===
190const MissionStatus = memo(function MissionStatus() {
191 const { state, dispatch } = useMission();
192 const { t } = useLanguage();
193
194 const handleLaunch = useCallback(() => dispatch({ type: 'LAUNCH_MISSION' }), [dispatch]);
195 const handleAbort = useCallback(() => dispatch({ type: 'ABORT_MISSION' }), [dispatch]);
196 const handleBurn = useCallback(() => dispatch({ type: 'UPDATE_FUEL', payload: -10 }), [dispatch]);
197
198 return (
199 <div>
200 <div className="status-indicator">
201 <span className={'status-dot status-' + state.status} />
202 <span>{t(state.status)}</span>
203 </div>
204 <div className="fuel-bar">
205 <span>{t('fuel')}: {state.fuel}%</span>
206 <div className="fuel-track">
207 <div className={'fuel-fill' + (state.fuel < 20 ? ' fuel-low' : '')} style={{ width: state.fuel + '%' }} />
208 </div>
209 </div>
210 <div className="status-actions">
211 {state.status === 'ready' && <button className="btn-launch" onClick={handleLaunch}>{t('launch')}</button>}
212 {state.status === 'inProgress' && (
213 <>
214 <button className="btn-abort" onClick={handleAbort}>{t('abort')}</button>
215 <button className="btn-fuel" onClick={handleBurn}>{t('burnFuel')}</button>
216 </>
217 )}
218 </div>
219 </div>
220 );
221});
222
223// Czujnik, który na żądanie rzuca błąd podczas renderowania (test Error Boundary)
224function SensorArray({ broken }) {
225 if (broken) {
226 throw new Error('Awaria czujników systemów');
227 }
228 return null;
229}
230
231// === LISTA SYSTEMÓW ===
232const SystemsList = memo(function SystemsList() {
233 const { state, dispatch } = useMission();
234 const { t } = useLanguage();
235 const [broken, setBroken] = useState(false);
236
237 const handleToggle = useCallback(id => dispatch({ type: 'TOGGLE_SYSTEM', payload: id }), [dispatch]);
238 const activeCount = useMemo(() => state.systems.filter(s => s.active).length, [state.systems]);
239
240 return (
241 <div>
242 <SensorArray broken={broken} />
243 <p className="systems-count">{activeCount}/{state.systems.length} {t('active')}</p>
244 {state.systems.map(system => (
245 <div key={system.id} className="system-item">
246 <span>{t(system.key)}</span>
247 <button className={system.active ? 'sys-on' : 'sys-off'} onClick={() => handleToggle(system.id)}>
248 {system.active ? t('on') : t('off')}
249 </button>
250 </div>
251 ))}
252 <button className="btn-crash" onClick={() => setBroken(true)}>{t('simulateCrash')}</button>
253 </div>
254 );
255});
256
257// === LISTA ZAŁOGI ===
258const CrewList = memo(function CrewList() {
259 const { state, dispatch } = useMission();
260 const { t } = useLanguage();
261 const [name, setName] = useState('');
262
263 const handleAdd = useCallback(() => {
264 if (!name.trim()) return;
265 // Identyfikator powstaje w handlerze, bo reducer musi pozostać czystą funkcją
266 dispatch({ type: 'ADD_CREW_MEMBER', payload: { id: Date.now(), name: name.trim() } });
267 setName('');
268 }, [name, dispatch]);
269
270 return (
271 <div>
272 {state.crew.map(member => (
273 <div key={member.id} className="crew-item">
274 <strong>{member.name}</strong>
275 <span>{t(member.role)}</span>
276 </div>
277 ))}
278 <div className="crew-add">
279 <input
280 value={name}
281 onChange={e => setName(e.target.value)}
282 onKeyDown={e => e.key === 'Enter' && handleAdd()}
283 placeholder={t('namePlaceholder')}
284 />
285 <button onClick={handleAdd}>{t('addMember')}</button>
286 </div>
287 </div>
288 );
289});
290
291// === ALERT W PORTALU: renderowany poza drzewem dashboardu ===
292function AlertModal({ onDismiss }) {
293 const { t } = useLanguage();
294
295 return createPortal(
296 <div className="modal-overlay">
297 <div className="modal-content">
298 <h2>{t('alertTitle')}</h2>
299 <p>{t('lowFuel')}</p>
300 <button onClick={onDismiss}>{t('dismiss')}</button>
301 </div>
302 </div>,
303 document.body
304 );
305}
306
307// === GŁÓWNA APLIKACJA ===
308function MissionControl() {
309 const { state } = useMission();
310 const { t } = useLanguage();
311 const [dismissedAt, setDismissedAt] = useState(null);
312
313 // Alert wynika ze stanu: niskie paliwo w trakcie misji, chyba że zamknięto go przy tym poziomie
314 const showAlert = state.status === 'inProgress' && state.fuel < 20 && dismissedAt !== state.fuel;
315
316 return (
317 <>
318 <Dashboard>
319 <Dashboard.Header />
320 <div className="panels-grid">
321 <Dashboard.Panel title={t('status')}>
322 <MissionStatus />
323 </Dashboard.Panel>
324 <Dashboard.Panel title={t('systems')}>
325 <SystemsList />
326 </Dashboard.Panel>
327 <Dashboard.Panel title={t('crew')}>
328 <CrewList />
329 </Dashboard.Panel>
330 </div>
331 </Dashboard>
332 {showAlert && <AlertModal onDismiss={() => setDismissedAt(state.fuel)} />}
333 </>
334 );
335}
336
337export default function App() {
338 return (
339 <LanguageProvider>
340 <MissionProvider>
341 <MissionControl />
342 </MissionProvider>
343 </LanguageProvider>
344 );
345}Widzisz błąd w tej lekcji?