Type-Safe State Management

0/5 in this phase0/21 across the roadmap

📖 Concept

State management libraries like Redux Toolkit (RTK), Zustand, and Jotai have first-class TypeScript support. Typing state correctly prevents entire categories of bugs — wrong action payloads, accessing non-existent state, and stale selectors.

Redux Toolkit (RTK):

  • createSlice infers action types and payload types from reducers
  • Typed useSelector and useDispatch hooks via a custom RootState type
  • createAsyncThunk types async action lifecycle (pending/fulfilled/rejected)

Zustand: Simpler API, excellent TypeScript inference. Define the store type and Zustand infers everything.

Key patterns:

  • Typed root state — Derive from store: type RootState = ReturnType<typeof store.getState>
  • Typed dispatchtype AppDispatch = typeof store.dispatch
  • Typed selectors(state: RootState) => state.user.name
  • Discriminated union actions — Each action has a unique type with typed payload

🏠 Real-world analogy: Typed state management is like a typed spreadsheet. Each column has a defined type (text, number, date), and the spreadsheet rejects invalid entries. Without types, it's like a free-form notebook — anything goes, and errors are discovered when you try to use the data.

💻 Code Example

codeTap to expand ⛶
1// ========== REDUX TOOLKIT ==========
2// import { configureStore, createSlice, PayloadAction } from "@reduxjs/toolkit";
3
4// Define state shape
5interface TodoState {
6 items: Todo[];
7 filter: "all" | "active" | "completed";
8 loading: boolean;
9}
10
11interface Todo {
12 id: string;
13 text: string;
14 completed: boolean;
15 createdAt: number;
16}
17
18const initialState: TodoState = {
19 items: [],
20 filter: "all",
21 loading: false,
22};
23
24// createSlice — actions are auto-typed from reducers
25// const todoSlice = createSlice({
26// name: "todos",
27// initialState,
28// reducers: {
29// addTodo(state, action: PayloadAction<{ text: string }>) {
30// state.items.push({
31// id: crypto.randomUUID(),
32// text: action.payload.text,
33// completed: false,
34// createdAt: Date.now(),
35// });
36// },
37// toggleTodo(state, action: PayloadAction<{ id: string }>) {
38// const todo = state.items.find((t) => t.id === action.payload.id);
39// if (todo) todo.completed = !todo.completed;
40// },
41// removeTodo(state, action: PayloadAction<{ id: string }>) {
42// state.items = state.items.filter((t) => t.id !== action.payload.id);
43// },
44// setFilter(state, action: PayloadAction<TodoState["filter"]>) {
45// state.filter = action.payload;
46// },
47// },
48// });
49
50// Typed hooks (create once, use everywhere)
51// type RootState = ReturnType<typeof store.getState>;
52// type AppDispatch = typeof store.dispatch;
53// const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
54// const useAppDispatch = () => useDispatch<AppDispatch>();
55
56// Usage in component:
57// const todos = useAppSelector((state) => state.todos.items);
58// const dispatch = useAppDispatch();
59// dispatch(todoSlice.actions.addTodo({ text: "Learn TS" })); // ✅ Type-safe
60
61// ========== ZUSTAND ==========
62// import { create } from "zustand";
63
64interface AuthStore {
65 user: { id: string; name: string; email: string } | null;
66 token: string | null;
67 isAuthenticated: boolean;
68 login: (email: string, password: string) => Promise<void>;
69 logout: () => void;
70 updateProfile: (updates: Partial<{ name: string; email: string }>) => void;
71}
72
73// const useAuthStore = create<AuthStore>((set, get) => ({
74// user: null,
75// token: null,
76// isAuthenticated: false,
77//
78// login: async (email, password) => {
79// const res = await fetch("/api/login", {
80// method: "POST",
81// body: JSON.stringify({ email, password }),
82// });
83// const { user, token } = await res.json();
84// set({ user, token, isAuthenticated: true });
85// },
86//
87// logout: () => {
88// set({ user: null, token: null, isAuthenticated: false });
89// },
90//
91// updateProfile: (updates) => {
92// const current = get().user;
93// if (current) {
94// set({ user: { ...current, ...updates } });
95// }
96// },
97// }));
98
99// Usage: const { user, login } = useAuthStore();
100// Fully typed — login expects (string, string), user is typed or null
101
102// Typed selectors pattern
103// const userName = useAuthStore((state) => state.user?.name);
104// Type: string | undefined
105
106console.log("State management with full TypeScript support");

🏋️ Practice Exercise

Mini Exercise:

  1. Create a Redux Toolkit slice with typed actions and selectors for a shopping cart
  2. Build a Zustand store with typed state and actions for a theme manager
  3. Type an async thunk that fetches paginated data with typed request/response
  4. Create typed selector functions that derive computed state
  5. Use PayloadAction<T> to ensure action payloads match expected types

⚠️ Common Mistakes

  • Not creating typed hooks (useAppSelector, useAppDispatch) — using untyped useSelector loses all type safety

  • Typing Redux state as any — always define RootState = ReturnType<typeof store.getState> for full inference

  • Using useSelector(state => state.user) without RootState type — the selector parameter needs the typed root state

  • Not typing async thunk error cases — createAsyncThunk has pending, fulfilled, AND rejected states; type all three

  • In Zustand, not providing the generic type — create<StoreType>() is essential for type inference to work correctly

💼 Interview Questions

🎤 Mock Interview

Practice a live interview for Type-Safe State Management