Redux Toolkit & Middleware Patterns

📖 Concept

Redux Toolkit (RTK) is the standard way to use Redux. It reduces boilerplate with createSlice, createAsyncThunk, and integrates Immer for immutable updates. But at scale, the patterns you use with RTK matter more than using RTK itself.

Sagas vs Thunks — when to use each:

Criteria Thunks (createAsyncThunk) Sagas (redux-saga)
Complexity Simple async/await Generators, effects, channels
Learning curve Low High
Testing Mock API, assert state Generator step-through
Cancellation AbortController (manual) Built-in with takeLatest/takeEvery
Complex flows Difficult (nested thunks) Natural (fork, race, all)
Debouncing Manual implementation Built-in with debounce effect
Use when Simple CRUD, fetch → set state Complex flows: polling, WebSocket management, event coordination

At scale, the key patterns are:

  1. State normalization with createEntityAdapter
  2. RTK Query for API data (replaces manual thunks for CRUD)
  3. Middleware for cross-cutting concerns (logging, analytics, error tracking)
  4. Selector composition with createSelector (Reselect) for derived state

💻 Code Example

codeTap to expand ⛶
1// === REDUX TOOLKIT AT SCALE ===
2
3// 1. Normalized state with createEntityAdapter
4import {
5 createSlice,
6 createEntityAdapter,
7 createAsyncThunk,
8 createSelector
9} from '@reduxjs/toolkit';
10
11interface Product {
12 id: string;
13 name: string;
14 price: number;
15 categoryId: string;
16 inStock: boolean;
17}
18
19const productsAdapter = createEntityAdapter<Product>({
20 selectId: (product) => product.id,
21 sortComparer: (a, b) => a.name.localeCompare(b.name),
22});
23
24const productsSlice = createSlice({
25 name: 'products',
26 initialState: productsAdapter.getInitialState({
27 status: 'idle' as 'idle' | 'loading' | 'failed',
28 filters: { category: null as string | null, inStockOnly: false },
29 }),
30 reducers: {
31 setFilter: (state, action) => {
32 state.filters = { ...state.filters, ...action.payload };
33 },
34 },
35 extraReducers: (builder) => {
36 builder
37 .addCase(fetchProducts.pending, (state) => { state.status = 'loading'; })
38 .addCase(fetchProducts.fulfilled, (state, action) => {
39 state.status = 'idle';
40 productsAdapter.upsertMany(state, action.payload);
41 })
42 .addCase(fetchProducts.rejected, (state) => { state.status = 'failed'; });
43 },
44});
45
46// 2. Memoized selectors with createSelector
47const { selectAll, selectById } = productsAdapter.getSelectors(
48 (state: RootState) => state.products
49);
50
51const selectFilteredProducts = createSelector(
52 [selectAll, (state: RootState) => state.products.filters],
53 (products, filters) => {
54 let result = products;
55 if (filters.category) {
56 result = result.filter(p => p.categoryId === filters.category);
57 }
58 if (filters.inStockOnly) {
59 result = result.filter(p => p.inStock);
60 }
61 return result;
62 }
63 // Only recomputes when products or filters actually change
64);
65
66// 3. Custom middleware for cross-cutting concerns
67const analyticsMiddleware: Middleware = (store) => (next) => (action) => {
68 const result = next(action);
69
70 // Track specific actions
71 if (action.type === 'cart/addItem') {
72 analytics.track('item_added_to_cart', {
73 productId: action.payload.productId,
74 cartSize: store.getState().cart.items.length,
75 });
76 }
77
78 // Track all errors
79 if (action.type.endsWith('/rejected')) {
80 analytics.track('async_error', {
81 action: action.type,
82 error: action.error?.message,
83 });
84 }
85
86 return result;
87};
88
89// 4. Saga for complex async flow (WebSocket management)
90import { eventChannel, END } from 'redux-saga';
91import { take, put, call, fork, cancel, cancelled } from 'redux-saga/effects';
92
93function createWebSocketChannel(url: string) {
94 return eventChannel((emit) => {
95 const ws = new WebSocket(url);
96 ws.onmessage = (event) => emit(JSON.parse(event.data));
97 ws.onclose = () => emit(END);
98 ws.onerror = () => emit(END);
99 return () => ws.close();
100 });
101}
102
103function* watchWebSocket() {
104 const channel = yield call(createWebSocketChannel, 'wss://api.example.com/ws');
105
106 try {
107 while (true) {
108 const message = yield take(channel);
109
110 switch (message.type) {
111 case 'NEW_MESSAGE':
112 yield put(chatActions.messageReceived(message.payload));
113 break;
114 case 'USER_TYPING':
115 yield put(chatActions.userTyping(message.payload));
116 break;
117 case 'PRESENCE_UPDATE':
118 yield put(presenceActions.update(message.payload));
119 break;
120 }
121 }
122 } finally {
123 if (yield cancelled()) {
124 channel.close();
125 }
126 }
127}

🏋️ Practice Exercise

Redux & Middleware Exercises:

  1. Implement a Redux slice with createEntityAdapter and write selectors that filter by multiple criteria
  2. Build a custom analytics middleware that tracks all dispatched actions with timing data
  3. Implement a saga that manages WebSocket connections with automatic reconnection and exponential backoff
  4. Compare the code complexity of implementing a polling mechanism with thunks vs sagas
  5. Create an undo/redo middleware using the Command pattern
  6. Set up RTK Query for a REST API with automatic cache invalidation and optimistic updates

⚠️ Common Mistakes

  • Not using createEntityAdapter for lists — manually managing arrays with push/filter is error-prone and non-performant

  • Creating selectors inside components — they're recreated every render, defeating memoization; define selectors outside components

  • Using sagas for simple CRUD — createAsyncThunk is simpler and sufficient for fetch → set state flows

  • Not normalizing nested API responses — storing post.author as a nested object duplicates user data across posts

  • Dispatching many sequential actions instead of one action with the full payload — each dispatch triggers a re-render cycle

💼 Interview Questions

🎤 Mock Interview

Mock interview is powered by AI for Redux Toolkit & Middleware Patterns. Login to unlock this feature.