Kurs JavaScript i React · Moduł 12: React i TypeScript

Typowanie Context API i useReducer

6 min czytania
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. 1. Dlaczego zaleca się używanie createContext<Type | undefined>(undefined) zamiast podawania domyślnego obiektu?

  2. 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

Przydatne artykuły