Kurs JavaScript i React · Moduł 4: Context API i useReducer

Projekt: Galaktyczne Centrum Dowodzenia

3 min czytania
W tej lekcji5

Przez cały ten moduł budowałeś wiedzę krok po kroku: Context API, reducery, łączenie tych narzędzi, optymalizacja. Teraz czas połączyć to wszystko w jeden projekt - Galaktyczne Centrum Dowodzenia (Galactic Command Center).

To nie jest kolejna lekcja teoretyczna. To projekt, który sam zbudujesz, używając wszystkiego czego się nauczyłeś.

Co zbudujesz?

Aplikację zarządzającą flotą statków kosmicznych z trzema niezależnymi systemami, z których każdy ma własny kontekst:

  • ThemeContext - przełącznik motywu (tryb dzienny "Sunrise" / nocny "Deep Space")
  • AuthContext - system autoryzacji (Kapitan zalogowany / wylogowany)
  • MissionContext - serce aplikacji: zarządzanie misjami przez useReducer

Komponenty na ekranie będą konsumować różne konteksty, zależnie od swoich potrzeb.

Wymagania projektu

1. ThemeContext

Kontekst przechowuje aktualny motyw i funkcję przełączającą. Komponent ThemeToggle zmienia motyw, a reszta aplikacji reaguje na zmianę (np. klasa CSS na głównym kontenerze).

1// Oczekiwana struktura kontekstu
2{
3  theme: 'dark' | 'light',
4  toggleTheme: () => void
5}

2. AuthContext

Kontekst przechowuje dane kapitana i umożliwia logowanie/wylogowanie. Formularz logowania powinien przyjmować imię kapitana. Po zalogowaniu wyświetlaj powitanie i przycisk wylogowania.

1// Oczekiwana struktura kontekstu
2{
3  captain: { name: string } | null,
4  login: (name: string) => void,
5  logout: () => void
6}

3. MissionContext z useReducer

To główna część projektu. Reducer obsługuje co najmniej 4 typy akcji:

1// Typy akcji do zaimplementowania
2'ADD_MISSION'      // dodaje nową misję do listy
3'UPDATE_STATUS'    // zmienia status istniejącej misji
4'ASSIGN_SHIP'      // przypisuje statek do misji
5'COMPLETE_MISSION' // oznacza misję jako zakończoną
6
7// Przykładowy kształt stanu
8{
9  missions: [
10    {
11      id: 1,
12      name: 'Eksploracja Marsa',
13      status: 'planning' | 'active' | 'completed',
14      assignedShip: string | null
15    }
16  ],
17  availableShips: ['USS Nova', 'Orion-7', 'Celestia']
18}

Struktura komponentów

Aplikacja powinna składać się z następujących komponentów:

1function App() {
2  return (
3    <ThemeProvider>
4      <AuthProvider>
5        <MissionProvider>
6          <div className="app">
7            <Header />        {/* używa AuthContext + ThemeContext */}
8            <MissionDashboard /> {/* używa MissionContext + AuthContext */}
9            <ThemeToggle />   {/* używa ThemeContext */}
10          </div>
11        </MissionProvider>
12      </AuthProvider>
13    </ThemeProvider>
14  );
15}

Header - wyświetla tytuł aplikacji, status zalogowania (z AuthContext) i aktualny motyw.

MissionDashboard - lista misji, formularz dodawania nowej misji, przypisywanie statków. Dostępny tylko dla zalogowanego kapitana.

ThemeToggle - przycisk przełączający między trybem dziennym i nocnym.

Wskazówki implementacyjne

Zacznij od zbudowania każdego providera osobno i przetestowania go zanim połączysz wszystkie razem. Dobrą kolejnością jest: ThemeProvider (najprostszy) → AuthProvider → MissionProvider.

Pamiętaj o niestandardowych hookach dla każdego kontekstu:

1function useTheme() {
2  const ctx = useContext(ThemeContext);
3  if (!ctx) throw new Error('useTheme musi być wewnątrz ThemeProvider');
4  return ctx;
5}
6
7function useAuth() {
8  const ctx = useContext(AuthContext);
9  if (!ctx) throw new Error('useAuth musi być wewnątrz AuthProvider');
10  return ctx;
11}
12
13function useMissions() {
14  const ctx = useContext(MissionContext);
15  if (!ctx) throw new Error('useMissions musi być wewnątrz MissionProvider');
16  return ctx;
17}

Dla MissionContext zastosuj wzorzec podziału na kontekst stanu i kontekst dispatch, którego nauczyłeś się w poprzedniej lekcji - to dobra praktyka przy złożonym stanie z useReducer.

Co demonstrować w projekcie

Gotowy projekt powinien pokazywać:

  1. Trzy niezależne systemy kontekstu działające równocześnie
  2. Komponent Header korzystający jednocześnie z AuthContext i ThemeContext
  3. Zmianę motywu natychmiast widoczną w całej aplikacji
  4. Dodawanie misji i zmianę ich statusu przez dispatch
  5. Blokadę panelu misji dla niezalogowanych użytkowników
  6. Przypisanie statku do misji (akcja ASSIGN_SHIP z payloadem)

Projekt jest celowo opisany ogólnie - to Twoja szansa na twórcze podejście do rozwiązania. Skorzystaj z edytora poniżej, żeby zbudować swoją wersję Galaktycznego Centrum Dowodzenia.

Kod do tej lekcji: App.jsx
1import React, { createContext, useContext, useReducer, useState } from 'react';
2
3// ============================================================
4// PROJEKT: Galaktyczne Centrum Dowodzenia
5// Zadanie: Uzupełnij brakujące fragmenty kodu (oznaczone TODO)
6// ============================================================
7
8// --- 1. THEME CONTEXT ---
9const ThemeContext = createContext();
10
11function ThemeProvider({ children }) {
12  const [darkMode, setDarkMode] = useState(true);
13  // TODO: Dodaj do value również funkcję toggleTheme
14  return (
15    <ThemeContext.Provider value={{ darkMode, toggleTheme: () => setDarkMode(d => !d) }}>
16      {children}
17    </ThemeContext.Provider>
18  );
19}
20
21function useTheme() {
22  // TODO: Użyj useContext aby odczytać ThemeContext
23  return useContext(ThemeContext);
24}
25
26// --- 2. AUTH CONTEXT ---
27const AuthContext = createContext();
28
29const authInitialState = { user: null, isLoggedIn: false };
30
31function authReducer(state, action) {
32  switch (action.type) {
33    case 'LOGIN':
34      // TODO: Zwróć nowy state z user: action.user i isLoggedIn: true
35      return { ...state, user: action.user, isLoggedIn: true };
36    case 'LOGOUT':
37      return authInitialState;
38    default:
39      return state;
40  }
41}
42
43function AuthProvider({ children }) {
44  const [state, dispatch] = useReducer(authReducer, authInitialState);
45  return (
46    <AuthContext.Provider value={{ ...state, dispatch }}>
47      {children}
48    </AuthContext.Provider>
49  );
50}
51
52function useAuth() { return useContext(AuthContext); }
53
54// --- 3. MISSION CONTEXT ---
55const MissionContext = createContext();
56
57const missionInitialState = { missions: [] };
58
59function missionReducer(state, action) {
60  switch (action.type) {
61    case 'ADD_MISSION':
62      // TODO: Dodaj nową misję do tablicy missions
63      // Misja ma pola: id, name, status: 'pending', createdBy
64      return {
65        ...state,
66        missions: [
67          ...state.missions,
68          { id: Date.now(), name: action.name, status: 'pending', createdBy: action.createdBy },
69        ],
70      };
71    case 'UPDATE_STATUS':
72      return {
73        ...state,
74        missions: state.missions.map(m =>
75          m.id === action.id ? { ...m, status: action.status } : m
76        ),
77      };
78    default:
79      return state;
80  }
81}
82
83function MissionProvider({ children }) {
84  const [state, dispatch] = useReducer(missionReducer, missionInitialState);
85  // TODO: Owiń children w MissionContext.Provider z value={{ ...state, dispatch }}
86  return (
87    <MissionContext.Provider value={{ ...state, dispatch }}>
88      {children}
89    </MissionContext.Provider>
90  );
91}
92
93function useMission() { return useContext(MissionContext); }
94
95// --- KOMPONENTY UI ---
96
97function LoginPanel() {
98  const { isLoggedIn, user, dispatch } = useAuth();
99  const [name, setName] = useState('');
100  if (isLoggedIn) {
101    return (
102      <div style={panel}>
103        <span style={{ color: '#4fc3f7' }}>Zalogowany: <b>{user}</b></span>
104        <button onClick={() => dispatch({ type: 'LOGOUT' })} style={{ ...btn, marginLeft: 12 }}>
105          Wyloguj
106        </button>
107      </div>
108    );
109  }
110  return (
111    <div style={panel}>
112      <input
113        value={name} onChange={e => setName(e.target.value)}
114        placeholder="Twoje imię (pilot)..." style={input}
115      />
116      <button
117        onClick={() => { if (name.trim()) dispatch({ type: 'LOGIN', user: name.trim() }); }}
118        style={{ ...btn, background: '#4fc3f7', color: '#0a0e1a' }}
119      >
120        Zaloguj się
121      </button>
122    </div>
123  );
124}
125
126function MissionPanel() {
127  const { isLoggedIn, user } = useAuth();
128  const { missions, dispatch } = useMission();
129  const [newMission, setNewMission] = useState('');
130  const statusLabels = { pending: '⏳ Oczekuje', active: 'Aktywna', done: 'Ukończona' };
131  const statusColors = { pending: '#ff9800', active: '#4fc3f7', done: '#4caf50' };
132
133  if (!isLoggedIn) {
134    return (
135      <div style={{ ...panel, color: '#778da9', textAlign: 'center' }}>
136        Zaloguj się, aby zarządzać misjami
137      </div>
138    );
139  }
140
141  return (
142    <div style={panel}>
143      <h3 style={{ color: '#4fc3f7', marginTop: 0 }}>Misje kosmiczne</h3>
144      <div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
145        <input
146          value={newMission} onChange={e => setNewMission(e.target.value)}
147          placeholder="Nazwa misji..." style={{ ...input, flex: 1 }}
148        />
149        <button
150          onClick={() => {
151            // TODO: Wywołaj dispatch z type: 'ADD_MISSION', name: newMission, createdBy: user
152            if (newMission.trim()) {
153              dispatch({ type: 'ADD_MISSION', name: newMission.trim(), createdBy: user });
154              setNewMission('');
155            }
156          }}
157          style={{ ...btn, background: '#4fc3f7', color: '#0a0e1a' }}
158        >
159          Dodaj
160        </button>
161      </div>
162      {missions.length === 0 && (
163        <p style={{ color: '#555', textAlign: 'center' }}>Brak misji. Dodaj pierwszą!</p>
164      )}
165      {missions.map(m => (
166        <div key={m.id} style={{ ...missionRow }}>
167          <div>
168            <div style={{ fontWeight: 'bold' }}>{m.name}</div>
169            <div style={{ fontSize: 11, color: '#778da9' }}>Pilot: {m.createdBy}</div>
170          </div>
171          <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
172            <span style={{ color: statusColors[m.status], fontSize: 12 }}>
173              {statusLabels[m.status]}
174            </span>
175            {m.status === 'pending' && (
176              <button onClick={() => dispatch({ type: 'UPDATE_STATUS', id: m.id, status: 'active' })} style={btn}>
177                Aktywuj
178              </button>
179            )}
180            {m.status === 'active' && (
181              <button onClick={() => dispatch({ type: 'UPDATE_STATUS', id: m.id, status: 'done' })} style={btn}>
182                Zakończ
183              </button>
184            )}
185          </div>
186        </div>
187      ))}
188    </div>
189  );
190}
191
192function ThemeToggle() {
193  const { darkMode, toggleTheme } = useTheme();
194  return (
195    <button onClick={toggleTheme} style={{ ...btn, float: 'right', marginBottom: 12 }}>
196      {darkMode ? 'Jasny motyw' : 'Ciemny motyw'}
197    </button>
198  );
199}
200
201// --- GŁÓWNA APLIKACJA ---
202// TODO: Owiń App w ThemeProvider, AuthProvider i MissionProvider (kolejność ma znaczenie!)
203function App() {
204  const { darkMode } = useTheme();
205  const bg = darkMode ? '#0a0e1a' : '#e8f4fd';
206  const fg = darkMode ? 'white' : '#0a0e1a';
207
208  return (
209    <div style={{ minHeight: '100vh', background: bg, padding: 24, fontFamily: 'Arial', color: fg, transition: 'all 0.3s' }}>
210      <ThemeToggle />
211      <h1 style={{ textAlign: 'center', color: '#ffd700', marginBottom: 24, clear: 'both' }}>
212        Galaktyczne Centrum Dowodzenia
213      </h1>
214      <LoginPanel />
215      <MissionPanel />
216    </div>
217  );
218}
219
220function WrappedApp() {
221  return (
222    <ThemeProvider>
223      <AuthProvider>
224        <MissionProvider>
225          <App />
226        </MissionProvider>
227      </AuthProvider>
228    </ThemeProvider>
229  );
230}
231
232// Style
233const panel = {
234  background: '#1a2035', borderRadius: 10, padding: 16, marginBottom: 14,
235  border: '1px solid rgba(79,195,247,0.2)',
236};
237const btn = {
238  background: 'rgba(255,255,255,0.08)', color: 'white',
239  border: '1px solid rgba(255,255,255,0.2)', borderRadius: 6,
240  padding: '5px 12px', cursor: 'pointer', fontSize: 12,
241};
242const input = {
243  background: '#0a0e1a', color: 'white',
244  border: '1px solid rgba(79,195,247,0.3)', borderRadius: 6,
245  padding: '6px 10px', boxSizing: 'border-box',
246};
247const missionRow = {
248  display: 'flex', justifyContent: 'space-between', alignItems: 'center',
249  padding: '8px 0', borderBottom: '1px solid rgba(255,255,255,0.07)',
250};
251
252export { WrappedApp as default };

Widzisz błąd w tej lekcji?

Przydatne artykuły