JavaScript and React course Β· Module 12: React and TypeScript

Typing Context API and useReducer

6 min read
In this lesson5

The color theme has to reach every panel from the bridge, and the mission list every cabin. Passing props through five levels of components is exhausting, and without types it's easy to dispatch an action with a typo that the reducer silently ignores. The Context API is a communication system between distant spaceship modules - it lets you pass data without threading it through every intermediate component. TypeScript ensures this data always has the correct shape.

Typing the Context API

We build a typed context in four steps. First an interface describing what the context provides, then the context itself with createContext. We create the context with the value undefined and the type ThemeContextType | undefined - I'll explain why in a moment.

1import React, { createContext, useContext, useState } from 'react';
2
3// 1. Define the context type
4interface ThemeContextType {
5  theme: "dark" | "light";
6  toggleTheme: () => void;
7}
8
9// 2. Create context with a default value
10const ThemeContext = createContext<ThemeContextType | undefined>(undefined);

The third step is a custom hook that reads the context and checks whether the component is inside a Provider at all. The fourth is the Provider, the component that holds the state and shares it with its descendants:

1// 3. Custom hook to use context (with validation)
2function useTheme(): ThemeContextType {
3  const context = useContext(ThemeContext);
4  if (context === undefined) {
5    throw new Error("useTheme must be used inside ThemeProvider");
6  }
7  return context;
8}
9
10// 4. Provider component
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}

After the if statement, TypeScript narrows context to ThemeContextType, so the hook returns a type without undefined. New in React 19: you can render the context itself as the Provider by writing <ThemeContext value={...}>. <ThemeContext.Provider> still works and you'll see it in most code, but the React team has announced it will be deprecated in a future version.

Why Use undefined as the Default Value?

The default value in createContext only reaches components that have no Provider above them. Compare the two approaches:

1// Pattern: createContext<T | undefined>(undefined)
2// + custom hook with validation
3
4// ADVANTAGES:
5// - Runtime error if component is not inside a Provider
6// - Type in component is always ThemeContextType (not undefined)
7// - Safer than providing an "empty" default object
8
9// ALTERNATIVE (less safe):
10const ThemeContext = createContext<ThemeContextType>({
11  theme: "dark",
12  toggleTheme: () => {} // empty function - silent bug
13});

In the alternative, a component outside the Provider gets an empty function - the theme button simply does nothing and nobody finds out why. The undefined pattern turns silent failures into a loud message. This is the pattern I recommend.

Typing useReducer

useReducer is ideal for managing complex state. A reducer is a pure function that receives the current state and an action and returns the new state. TypeScript makes every action and state change safe. We start with the state type and the type of a single mission:

1// 1. Define the state type
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}

We describe actions as a discriminated union from the previous lesson. The discriminator is the type field, and each variant has its own payload type. Mission["status"] takes the type of the status field from the interface, so we don't repeat the list of statuses:

1// 2. Define action types - 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 };

Now the reducer. In every case, TypeScript knows the exact type of action.payload: in "ADD_MISSION" it's Mission, in "SET_LOADING" it's boolean.

1// 3. Reducer with full typing
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}

The reducer never modifies the old state - it always returns a new object via ...state. The action { type: "ADD_MISION" } with a typo won't compile, because it matches no variant.

Combining Context + useReducer

Now we connect the reducer to a context, so every fleet component can read the state and dispatch actions. The React.Dispatch<MissionAction> type describes a dispatch function that accepts only valid actions:

1// Initial state
2const initialState: MissionState = {
3  missions: [],
4  selectedMission: null,
5  loading: false,
6  error: null
7};
8
9// Context type
10interface MissionContextType {
11  state: MissionState;
12  dispatch: React.Dispatch<MissionAction>;
13}
14
15const MissionContext = createContext<MissionContextType | undefined>(undefined);

The hook and the Provider repeat the theme pattern. Notice that useReducer gets no generics - it infers the types from missionReducer and initialState. The hook's type signature changed in React 19 and the old useReducer<State, Action>(...) form now reports an error, so rely on inference.

1// Custom hook
2function useMissions(): MissionContextType {
3  const context = useContext(MissionContext);
4  if (!context) {
5    throw new Error("useMissions must be used inside 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}

Using It in Components

The mission list component gets the state and dispatch with one hook call. The editor suggests the allowed type values, and after you pick "SELECT_MISSION" it requires a payload of type string:

1function MissionList() {
2  const { state, dispatch } = useMissions();
3
4  if (state.loading) return <p>Loading missions...</p>;
5  if (state.error) return <p>Error: {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 knows nothing about where the state lives - it only knows the hook. In the final project the same setup will handle the theme of the whole dashboard.

Code for this lesson: App.tsx
1import React, { createContext, useContext, useReducer, useState } from 'react';
2
3// ===== TYPES =====
4interface CrewMember {
5  id: string;
6  name: string;
7  role: string;
8  status: "active" | "resting" | "mission";
9}
10
11// Application state
12interface AppState {
13  crew: CrewMember[];
14  theme: "dark" | "light";
15  notifications: string[];
16}
17
18// Discriminated union of actions
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, "Added: " + 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, "Removed a crew member"]
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 requires AppProvider");
71  return context;
72}
73
74// ===== PROVIDER =====
75const initialState: AppState = {
76  crew: [
77    { id: "1", name: "Nova", role: "Captain", status: "active" },
78    { id: "2", name: "Ra", role: "Navigator", status: "active" },
79    { id: "3", name: "Astro", role: "Engineer", 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// ===== COMPONENTS =====
95function Header() {
96  const { state, dispatch } = useApp();
97  return (
98    <header className="header">
99      <h1>Command Center</h1>
100      <button onClick={() => dispatch({ type: "TOGGLE_THEME" })}>
101        {state.theme === "dark" ? "Light" : "Dark"} theme
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>Crew ({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="Name" />
163      <input value={role} onChange={e => setRole(e.target.value)} placeholder="Role" />
164      <button type="submit">Add</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>Notifications ({state.notifications.length})</h2>
175      {state.notifications.map((n, i) => <p key={i} className="notif">{n}</p>)}
176      <button onClick={() => dispatch({ type: "CLEAR_NOTIFICATIONS" })}>Clear</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;

Remember: context is the fleet's communication network and the action type is its cipher - a message with the wrong cipher never leaves the bridge.

Spotted a mistake in this lesson?

Check yourself

Answer the questions from this lesson. Pick an answer to see right away whether it is correct.

  1. 1. Why is it recommended to use createContext<Type | undefined>(undefined) instead of providing a default object?

  2. 2. Why is it best to define action types in useReducer as a discriminated union?

These are 2 of 3 questions for this lesson. Solve the rest in the game.

Hands-on tasks in the game

  • Vertical ordering

    Arrange the steps for creating a typed Context with useReducer in the correct order:

  • Code editor

    Full typing of Context API with useReducer

  • Click in order

    Arrange the code of a custom hook with context validation:

  • Horizontal ordering

    Arrange the correct type for dispatch in the context interface:

  • Vertical ordering

    Arrange TypeScript concepts in React from simplest to most complex:

  • Horizontal ordering

    Arrange the syntax for creating a typed React context:

  • Click in order

    Arrange the signature of a typed reducer function:

  • Vertical ordering

    Arrange the data flow in Context + useReducer architecture in the correct order:

  • Horizontal ordering

    Arrange a correct dispatch call with a typed action:

  • Click in order

    Arrange the elements of a typed Provider component:

  • Vertical ordering

    Arrange the benefits of TypeScript in React from the most basic to the most advanced:

  • Code editor

    Comprehensive exercise combining all TypeScript concepts in React

Useful articles