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:
- State normalization with
createEntityAdapter - RTK Query for API data (replaces manual thunks for CRUD)
- Middleware for cross-cutting concerns (logging, analytics, error tracking)
- Selector composition with
createSelector(Reselect) for derived state
💻 Code Example
1// === REDUX TOOLKIT AT SCALE ===23// 1. Normalized state with createEntityAdapter4import {5 createSlice,6 createEntityAdapter,7 createAsyncThunk,8 createSelector9} from '@reduxjs/toolkit';1011interface Product {12 id: string;13 name: string;14 price: number;15 categoryId: string;16 inStock: boolean;17}1819const productsAdapter = createEntityAdapter<Product>({20 selectId: (product) => product.id,21 sortComparer: (a, b) => a.name.localeCompare(b.name),22});2324const 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 builder37 .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});4546// 2. Memoized selectors with createSelector47const { selectAll, selectById } = productsAdapter.getSelectors(48 (state: RootState) => state.products49);5051const 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 change64);6566// 3. Custom middleware for cross-cutting concerns67const analyticsMiddleware: Middleware = (store) => (next) => (action) => {68 const result = next(action);6970 // Track specific actions71 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 }7778 // Track all errors79 if (action.type.endsWith('/rejected')) {80 analytics.track('async_error', {81 action: action.type,82 error: action.error?.message,83 });84 }8586 return result;87};8889// 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';9293function 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}102103function* watchWebSocket() {104 const channel = yield call(createWebSocketChannel, 'wss://api.example.com/ws');105106 try {107 while (true) {108 const message = yield take(channel);109110 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:
- Implement a Redux slice with createEntityAdapter and write selectors that filter by multiple criteria
- Build a custom analytics middleware that tracks all dispatched actions with timing data
- Implement a saga that manages WebSocket connections with automatic reconnection and exponential backoff
- Compare the code complexity of implementing a polling mechanism with thunks vs sagas
- Create an undo/redo middleware using the Command pattern
- 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.authoras a nested object duplicates user data across postsDispatching 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.