Kurs JavaScript i React · Moduł 10: Ekosystem i przyszłość React

Projekt główny: Kosmiczne Centrum Dowodzenia Misją

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

  1. Dashboard wyświetla 3 panele: Status Misji, Systemy, Załoga
  2. Przełącznik języka (PL/EN) zmienia wszystkie etykiety interfejsu
  3. Przycisk startu misji zmienia status i zużywa paliwo (useReducer)
  4. Systemy można włączać/wyłączać (toggle)
  5. Alert modal (Portal) pojawia się gdy paliwo spadnie poniżej 20%
  6. Error Boundary wyświetla fallback gdy komponent rzuci błąd
  7. 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:

  1. Zacznij od MissionProvider z reducerem i jednego panelu, który pokazuje status i paliwo.
  2. Dodaj LanguageProvider i przepuść wszystkie etykiety przez funkcję tłumaczącą.
  3. Zbuduj Dashboard z Dashboard.Header i Dashboard.Panel, a w każdym panelu umieść ErrorBoundary.
  4. Dopisz alarm w portalu, który pojawia się, gdy paliwo spadnie poniżej 20%.
  5. Na końcu zmierz rendery w React DevTools i dodaj React.memo, useMemo oraz useCallback tam, 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?

Przydatne artykuły