Kurs JavaScript i React · Moduł 12: React i TypeScript
Typowanie Context API i useReducer
W tej lekcji5
Motyw kolorystyczny musi dotrzeć z mostka do każdego panelu, a lista misji do każdej kabiny. Przekazywanie props przez pięć poziomów komponentów męczy, a bez typów łatwo wysłać akcję z literówką, której reducer po cichu nie obsłuży. Context API to system komunikacji między odległymi modułami statku kosmicznego - pozwala przesyłać dane bez przekazywania ich przez każdy pośredni komponent. TypeScript zapewnia, że te dane zawsze mają prawidłowy kształt.
Typowanie Context API
Typowany kontekst budujemy w czterech krokach. Najpierw interfejs opisujący, co kontekst udostępnia, potem sam kontekst z createContext. Kontekst tworzymy z wartością undefined i typem ThemeContextType | undefined - za chwilę wyjaśnię dlaczego.
1import React, { createContext, useContext, useState } from 'react';
2
3// 1. Zdefiniuj typ kontekstu
4interface ThemeContextType {
5 theme: "dark" | "light";
6 toggleTheme: () => void;
7}
8
9// 2. Utwórz kontekst z domyślną wartością
10const ThemeContext = createContext<ThemeContextType | undefined>(undefined);Trzeci krok to własny hook, który odczytuje kontekst i sprawdza, czy komponent w ogóle znajduje się wewnątrz Providera. Czwarty to Provider, czyli komponent przechowujący stan i udostępniający go potomkom:
1// 3. Custom hook do użycia kontekstu (z walidacją)
2function useTheme(): ThemeContextType {
3 const context = useContext(ThemeContext);
4 if (context === undefined) {
5 throw new Error("useTheme musi być używany wewnątrz ThemeProvider");
6 }
7 return context;
8}
9
10// 4. Provider komponent
11function ThemeProvider({ children }: { children: React.ReactNode }) {
12 const [theme, setTheme] = useState<"dark" | "light">("dark");
13
14 const toggleTheme = () => {
15 setTheme(prev => prev === "dark" ? "light" : "dark");
16 };
17
18 return (
19 <ThemeContext.Provider value={{ theme, toggleTheme }}>
20 {children}
21 </ThemeContext.Provider>
22 );
23}Po instrukcji if TypeScript zawęża context do ThemeContextType, więc hook zwraca typ bez undefined. Nowość w React 19: kontekst możesz renderować bezpośrednio jako Provider, pisząc <ThemeContext value={...}>. Zapis <ThemeContext.Provider> nadal działa i spotkasz go w większości kodu, ale zespół React zapowiada, że w przyszłości zostanie oznaczony jako przestarzały.
Dlaczego używać undefined jako domyślna wartość?
Wartość domyślna w createContext trafia tylko do komponentów, nad którymi nie ma żadnego Providera. Porównaj dwa podejścia:
1// Wzorzec: createContext<T | undefined>(undefined)
2// + custom hook z walidacją
3
4// ZALETY:
5// - Błąd w runtime jeśli komponent nie jest wewnątrz Providera
6// - Typ w komponencie jest zawsze ThemeContextType (nie undefined)
7// - Bezpieczniejsze niż podawanie "pustego" obiektu domyślnego
8
9// ALTERNATYWA (mniej bezpieczna):
10const ThemeContext = createContext<ThemeContextType>({
11 theme: "dark",
12 toggleTheme: () => {} // pusta funkcja - cichy błąd
13});W wersji alternatywnej komponent poza Providerem dostanie pustą funkcję - przycisk zmiany motywu po prostu nic nie zrobi i nikt nie dowie się dlaczego. Wzorzec z undefined zamienia ciche awarie w głośny komunikat. Ten właśnie wzorzec polecam.
Typowanie useReducer
useReducer jest idealny do zarządzania złożonym stanem. Reducer to czysta funkcja, która dostaje obecny stan i akcję, a zwraca nowy stan. TypeScript sprawia, że każda akcja i zmiana stanu są bezpieczne. Zaczynamy od typu stanu i typu pojedynczej misji:
1// 1. Zdefiniuj typ stanu
2interface MissionState {
3 missions: Mission[];
4 selectedMission: Mission | null;
5 loading: boolean;
6 error: string | null;
7}
8
9interface Mission {
10 id: string;
11 name: string;
12 status: "planned" | "active" | "completed";
13}Akcje opisujemy jako discriminated union z poprzedniej lekcji. Dyskryminatorem jest pole type, a każdy wariant ma własny typ payload. Zapis Mission["status"] pobiera typ pola status z interfejsu, więc nie powtarzamy listy statusów:
1// 2. Zdefiniuj typy akcji - discriminated union!
2type MissionAction =
3 | { type: "SET_MISSIONS"; payload: Mission[] }
4 | { type: "SELECT_MISSION"; payload: string }
5 | { type: "ADD_MISSION"; payload: Mission }
6 | { type: "UPDATE_STATUS"; payload: { id: string; status: Mission["status"] } }
7 | { type: "SET_LOADING"; payload: boolean }
8 | { type: "SET_ERROR"; payload: string | null };Teraz reducer. W każdym case TypeScript zna dokładny typ action.payload: w "ADD_MISSION" to Mission, w "SET_LOADING" - boolean.
1// 3. Reducer z pełnym typowaniem
2function missionReducer(state: MissionState, action: MissionAction): MissionState {
3 switch (action.type) {
4 case "SET_MISSIONS":
5 return { ...state, missions: action.payload, loading: false };
6 case "SELECT_MISSION":
7 return {
8 ...state,
9 selectedMission: state.missions.find(m => m.id === action.payload) || null
10 };
11 case "ADD_MISSION":
12 return { ...state, missions: [...state.missions, action.payload] };
13 case "UPDATE_STATUS":
14 return {
15 ...state,
16 missions: state.missions.map(m =>
17 m.id === action.payload.id
18 ? { ...m, status: action.payload.status }
19 : m
20 )
21 };
22 case "SET_LOADING":
23 return { ...state, loading: action.payload };
24 case "SET_ERROR":
25 return { ...state, error: action.payload, loading: false };
26 default:
27 return state;
28 }
29}Reducer nigdy nie modyfikuje starego stanu - zawsze zwraca nowy obiekt przez ...state. Akcja { type: "ADD_MISION" } z literówką nie skompiluje się, bo nie pasuje do żadnego wariantu.
Łączenie Context + useReducer
Teraz podłączamy reducer do kontekstu, żeby każdy komponent floty mógł czytać stan i wysyłać akcje. Typ React.Dispatch<MissionAction> opisuje funkcję dispatch, która przyjmuje wyłącznie poprawne akcje:
1// Stan początkowy
2const initialState: MissionState = {
3 missions: [],
4 selectedMission: null,
5 loading: false,
6 error: null
7};
8
9// Typ kontekstu
10interface MissionContextType {
11 state: MissionState;
12 dispatch: React.Dispatch<MissionAction>;
13}
14
15const MissionContext = createContext<MissionContextType | undefined>(undefined);Hook i Provider powtarzają wzorzec z motywem. Zwróć uwagę, że useReducer nie dostaje żadnych generyków - typy wywnioskuje z missionReducer i initialState. W React 19 zmieniła się sygnatura typów tego hooka i stary zapis useReducer<State, Action>(...) zgłasza błąd, więc polegaj na wnioskowaniu.
1// Custom hook
2function useMissions(): MissionContextType {
3 const context = useContext(MissionContext);
4 if (!context) {
5 throw new Error("useMissions musi być używany wewnątrz MissionProvider");
6 }
7 return context;
8}
9
10// Provider
11function MissionProvider({ children }: { children: React.ReactNode }) {
12 const [state, dispatch] = useReducer(missionReducer, initialState);
13
14 return (
15 <MissionContext.Provider value={{ state, dispatch }}>
16 {children}
17 </MissionContext.Provider>
18 );
19}Użycie w komponentach
Komponent listy misji pobiera stan i dispatch jednym wywołaniem hooka. Edytor podpowie dozwolone wartości type, a po wybraniu "SELECT_MISSION" wymusi payload typu string:
1function MissionList() {
2 const { state, dispatch } = useMissions();
3
4 if (state.loading) return <p>Ładowanie misji...</p>;
5 if (state.error) return <p>Błąd: {state.error}</p>;
6
7 return (
8 <div>
9 {state.missions.map(mission => (
10 <div key={mission.id} onClick={() =>
11 dispatch({ type: "SELECT_MISSION", payload: mission.id })
12 }>
13 <h3>{mission.name}</h3>
14 <span>{mission.status}</span>
15 </div>
16 ))}
17 </div>
18 );
19}MissionList nie wie nic o tym, gdzie leży stan - zna tylko hook. W projekcie końcowym ten sam układ obsłuży motyw całego dashboardu.
Kod do tej lekcji: App.tsx
1import React, { createContext, useContext, useReducer, useState } from 'react';
2
3// ===== TYPY =====
4interface CrewMember {
5 id: string;
6 name: string;
7 role: string;
8 status: "active" | "resting" | "mission";
9}
10
11// Stan aplikacji
12interface AppState {
13 crew: CrewMember[];
14 theme: "dark" | "light";
15 notifications: string[];
16}
17
18// Discriminated union akcji
19type AppAction =
20 | { type: "ADD_CREW"; payload: CrewMember }
21 | { type: "REMOVE_CREW"; payload: string }
22 | { type: "UPDATE_STATUS"; payload: { id: string; status: CrewMember["status"] } }
23 | { type: "TOGGLE_THEME" }
24 | { type: "ADD_NOTIFICATION"; payload: string }
25 | { type: "CLEAR_NOTIFICATIONS" };
26
27// ===== REDUCER =====
28function appReducer(state: AppState, action: AppAction): AppState {
29 switch (action.type) {
30 case "ADD_CREW":
31 return {
32 ...state,
33 crew: [...state.crew, action.payload],
34 notifications: [...state.notifications, "Dodano: " + action.payload.name]
35 };
36 case "REMOVE_CREW":
37 return {
38 ...state,
39 crew: state.crew.filter(c => c.id !== action.payload),
40 notifications: [...state.notifications, "Usunieto czlonka zalogi"]
41 };
42 case "UPDATE_STATUS":
43 return {
44 ...state,
45 crew: state.crew.map(c =>
46 c.id === action.payload.id ? { ...c, status: action.payload.status } : c
47 )
48 };
49 case "TOGGLE_THEME":
50 return { ...state, theme: state.theme === "dark" ? "light" : "dark" };
51 case "ADD_NOTIFICATION":
52 return { ...state, notifications: [...state.notifications, action.payload] };
53 case "CLEAR_NOTIFICATIONS":
54 return { ...state, notifications: [] };
55 default:
56 return state;
57 }
58}
59
60// ===== CONTEXT =====
61interface AppContextType {
62 state: AppState;
63 dispatch: React.Dispatch<AppAction>;
64}
65
66const AppContext = createContext<AppContextType | undefined>(undefined);
67
68function useApp(): AppContextType {
69 const context = useContext(AppContext);
70 if (!context) throw new Error("useApp wymaga AppProvider");
71 return context;
72}
73
74// ===== PROVIDER =====
75const initialState: AppState = {
76 crew: [
77 { id: "1", name: "Nova", role: "Kapitan", status: "active" },
78 { id: "2", name: "Ra", role: "Nawigator", status: "active" },
79 { id: "3", name: "Astro", role: "Inzynier", status: "resting" },
80 ],
81 theme: "dark",
82 notifications: []
83};
84
85function AppProvider({ children }: { children: React.ReactNode }) {
86 const [state, dispatch] = useReducer(appReducer, initialState);
87 return (
88 <AppContext.Provider value={{ state, dispatch }}>
89 <div className={"app " + state.theme}>{children}</div>
90 </AppContext.Provider>
91 );
92}
93
94// ===== KOMPONENTY =====
95function Header() {
96 const { state, dispatch } = useApp();
97 return (
98 <header className="header">
99 <h1>Centrum Dowodzenia</h1>
100 <button onClick={() => dispatch({ type: "TOGGLE_THEME" })}>
101 {state.theme === "dark" ? "Jasny" : "Ciemny"} motyw
102 </button>
103 </header>
104 );
105}
106
107function CrewList() {
108 const { state, dispatch } = useApp();
109 const statusColors = { active: "#22c55e", resting: "#f59e0b", mission: "#818cf8" };
110 const statuses: CrewMember["status"][] = ["active", "resting", "mission"];
111
112 return (
113 <div className="section">
114 <h2>Zaloga ({state.crew.length})</h2>
115 {state.crew.map(member => (
116 <div key={member.id} className="crew-card">
117 <div>
118 <strong>{member.name}</strong> - {member.role}
119 </div>
120 <div className="crew-actions">
121 <select
122 value={member.status}
123 onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
124 dispatch({
125 type: "UPDATE_STATUS",
126 payload: { id: member.id, status: e.target.value as CrewMember["status"] }
127 })
128 }
129 >
130 {statuses.map(s => <option key={s} value={s}>{s}</option>)}
131 </select>
132 <span style={{ color: statusColors[member.status] }}>{member.status}</span>
133 <button className="small-btn danger"
134 onClick={() => dispatch({ type: "REMOVE_CREW", payload: member.id })}>
135 X
136 </button>
137 </div>
138 </div>
139 ))}
140 </div>
141 );
142}
143
144function AddCrewForm() {
145 const { dispatch } = useApp();
146 const [name, setName] = useState("");
147 const [role, setRole] = useState("");
148
149 const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
150 e.preventDefault();
151 if (!name || !role) return;
152 dispatch({
153 type: "ADD_CREW",
154 payload: { id: Date.now().toString(), name, role, status: "active" }
155 });
156 setName("");
157 setRole("");
158 };
159
160 return (
161 <form onSubmit={handleSubmit} className="add-form">
162 <input value={name} onChange={e => setName(e.target.value)} placeholder="Imie" />
163 <input value={role} onChange={e => setRole(e.target.value)} placeholder="Rola" />
164 <button type="submit">Dodaj</button>
165 </form>
166 );
167}
168
169function Notifications() {
170 const { state, dispatch } = useApp();
171 if (state.notifications.length === 0) return null;
172 return (
173 <div className="section notifications">
174 <h2>Powiadomienia ({state.notifications.length})</h2>
175 {state.notifications.map((n, i) => <p key={i} className="notif">{n}</p>)}
176 <button onClick={() => dispatch({ type: "CLEAR_NOTIFICATIONS" })}>Wyczysc</button>
177 </div>
178 );
179}
180
181function App() {
182 return (
183 <AppProvider>
184 <Header />
185 <AddCrewForm />
186 <CrewList />
187 <Notifications />
188 </AppProvider>
189 );
190}
191
192export default App;Pamiętaj: kontekst to sieć łączności floty, a typ akcji to jej szyfr - wiadomość z błędnym szyfrem nie opuści mostka.
Widzisz błąd w tej lekcji?
Sprawdź się
Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.
1. Dlaczego zaleca się używanie createContext<Type | undefined>(undefined) zamiast podawania domyślnego obiektu?
2. Dlaczego typy akcji w useReducer najlepiej definiować jako discriminated union?
To 2 z 3 pytań do tej lekcji. Pozostałe rozwiążesz w grze.
Zadania praktyczne w grze
- Układanie w pionie
Ułóż kolejność kroków tworzenia typowanego Context z useReducer:
- Edytor kodu
Pełne typowanie Context API z useReducer
- Klikanie w kolejności
Ułóż kod custom hooka z walidacją kontekstu:
- Układanie w poziomie
Ułóż poprawny typ dla dispatch w interfejsie kontekstu:
- Układanie w pionie
Ułóż koncepty TypeScript w React od najprostszego do najbardziej złożonego:
- Układanie w poziomie
Ułóż składnię tworzenia typowanego kontekstu React:
- Klikanie w kolejności
Ułóż sygnaturę typowanej funkcji reducer:
- Układanie w pionie
Ułóż kolejność przepływu danych w architekturze Context + useReducer:
- Układanie w poziomie
Ułóż poprawne wywołanie dispatch z typowaną akcją:
- Klikanie w kolejności
Ułóż elementy typowanego Provider komponentu:
- Układanie w pionie
Ułóż zalety TypeScript w React od najbardziej podstawowej do najbardziej zaawansowanej:
- Edytor kodu
Kompleksowe ćwiczenie łączące wszystkie koncepty TypeScript w React