Kurs JavaScript i React · Moduł 4: Context API i useReducer
Projekt: Galaktyczne Centrum Dowodzenia
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ć:
- Trzy niezależne systemy kontekstu działające równocześnie
- Komponent
Headerkorzystający jednocześnie zAuthContextiThemeContext - Zmianę motywu natychmiast widoczną w całej aplikacji
- Dodawanie misji i zmianę ich statusu przez dispatch
- Blokadę panelu misji dla niezalogowanych użytkowników
- Przypisanie statku do misji (akcja
ASSIGN_SHIPz 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?