The Problem with Traditional Redux
While Redux provides a powerful state management solution, it has been criticized for:
- Excessive boilerplate: Setting up actions, action creators, reducers, and the store requires a lot of repetitive code
- Configuration complexity: Configuring middleware, DevTools, and other store enhancers is complicated
- Indirect immutability: Manually ensuring state immutability is error-prone and verbose
- Lack of standardization: Different Redux projects often follow different patterns
Consider the example from our previous lecture, where setting up a simple counter required:
- Separate files for action types, action creators, reducers, and the store
- Verbose switch-case statements in reducers
- Manual state copying for immutability
- Boilerplate for DevTools configuration
Analogy: Using traditional Redux is like making coffee from scratch - grinding beans, measuring, setting up a filter, boiling water, etc. Redux Toolkit is like a programmable coffee machine - push a button and get your coffee, with all the complexity handled for you.
Introducing Redux Toolkit
Redux Toolkit (RTK) is the official, opinionated, batteries-included toolset for efficient Redux development. Created by the Redux team to address common concerns, it:
- Simplifies store setup and configuration
- Provides utilities to reduce boilerplate
- Includes built-in best practices
- Handles immutable updates automatically
- Includes common middleware like Redux Thunk
Installation
// With npm
npm install @reduxjs/toolkit react-redux
// With yarn
yarn add @reduxjs/toolkit react-redux
Core Redux Toolkit Features
configureStore
configureStore wraps the standard Redux createStore function with simplified configuration options and defaults. It automatically:
- Combines slice reducers into a root reducer
- Adds Redux Thunk middleware
- Enables Redux DevTools Extension
- Sets up development middleware (like serializable state checking)
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from './counterSlice';
import todosReducer from './todosSlice';
const store = configureStore({
reducer: {
counter: counterReducer,
todos: todosReducer
},
// Optional additional configuration
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(logger),
devTools: process.env.NODE_ENV !== 'production',
});
export default store;
createReducer
createReducer simplifies reducer creation by:
- Using a lookup table of action types to reducer functions (instead of switch-case)
- Providing automatic state immutability via Immer library
- Allowing direct "mutation" of state in reducers
import { createReducer } from '@reduxjs/toolkit';
import { increment, decrement, reset } from './actions';
const initialState = { count: 0 };
// Traditional reducer with switch-case
function counterReducer(state = initialState, action) {
switch (action.type) {
case 'INCREMENT':
return { ...state, count: state.count + 1 };
case 'DECREMENT':
return { ...state, count: state.count - 1 };
case 'RESET':
return { ...state, count: 0 };
default:
return state;
}
}
// Using createReducer with builder callback notation
const counterReducer = createReducer(initialState, (builder) => {
builder
.addCase(increment, (state) => {
// "mutate" state directly - Immer handles immutability
state.count += 1;
})
.addCase(decrement, (state) => {
state.count -= 1;
})
.addCase(reset, (state) => {
state.count = 0;
});
});
createAction
createAction generates action creator functions with less boilerplate:
- Automatically sets the action type
- Includes a payload creator for formatting the payload
- Adds a
toString()method to allow using the creator directly as the action type
import { createAction } from '@reduxjs/toolkit';
// Traditional action creators
const increment = () => ({ type: 'INCREMENT' });
const addTodo = (text) => ({
type: 'ADD_TODO',
payload: { id: Date.now(), text, completed: false }
});
// With createAction
const increment = createAction('INCREMENT');
// Calling increment() returns { type: 'INCREMENT' }
const addTodo = createAction('ADD_TODO', (text) => ({
payload: {
id: Date.now(),
text,
completed: false
}
}));
// Calling addTodo('Learn Redux') returns
// { type: 'ADD_TODO', payload: { id: 1234567890, text: 'Learn Redux', completed: false } }
createSlice: The Heart of Redux Toolkit
createSlice is the most powerful feature of Redux Toolkit. It combines createReducer and createAction to generate action creators and reducers from a single configuration object:
Anatomy of createSlice
import { createSlice } from '@reduxjs/toolkit';
const counterSlice = createSlice({
name: 'counter', // Namespace for action types
initialState: { count: 0 }, // Initial state
reducers: {
// Object of reducer functions
// Keys become action types (counter/increment)
// Values become case reducers
increment: (state) => {
state.count += 1;
},
decrement: (state) => {
state.count -= 1;
},
incrementByAmount: (state, action) => {
state.count += action.payload;
},
reset: (state) => {
state.count = 0;
}
}
});
// Extract the action creators
export const { increment, decrement, incrementByAmount, reset } = counterSlice.actions;
// Export the reducer function
export default counterSlice.reducer;
With this single createSlice call, we've created:
- A reducer function that handles all our counter logic
- Action creator functions for each reducer case
- Action type strings based on the slice name and reducer names
The equivalent code in traditional Redux would be at least 3-4 times longer and spread across multiple files.
Handling Actions with Prepare Callbacks
For more complex action payloads, you can use prepare callbacks:
const todosSlice = createSlice({
name: 'todos',
initialState: [],
reducers: {
// Using an object with reducer and prepare functions
addTodo: {
reducer: (state, action) => {
state.push(action.payload);
},
prepare: (text) => ({
payload: {
id: Date.now(),
text,
completed: false,
createdAt: new Date().toISOString()
}
})
},
toggleTodo: (state, action) => {
const todo = state.find(todo => todo.id === action.payload);
if (todo) {
todo.completed = !todo.completed;
}
}
}
});
Practical Example: Building a Complete Redux Store
Let's build a complete example with multiple slices, store configuration, and React integration:
1. Create Feature Slices
// features/counter/counterSlice.js
import { createSlice } from '@reduxjs/toolkit';
const counterSlice = createSlice({
name: 'counter',
initialState: {
value: 0,
status: 'idle'
},
reducers: {
increment: (state) => {
state.value += 1;
},
decrement: (state) => {
state.value -= 1;
},
incrementByAmount: (state, action) => {
state.value += action.payload;
}
}
});
export const { increment, decrement, incrementByAmount } = counterSlice.actions;
// Selectors
export const selectCount = (state) => state.counter.value;
export default counterSlice.reducer;
// features/todos/todosSlice.js
import { createSlice } from '@reduxjs/toolkit';
const todosSlice = createSlice({
name: 'todos',
initialState: {
items: [],
status: 'idle',
error: null
},
reducers: {
addTodo: {
reducer: (state, action) => {
state.items.push(action.payload);
},
prepare: (text) => ({
payload: {
id: Date.now().toString(),
text,
completed: false,
createdAt: new Date().toISOString()
}
})
},
toggleTodo: (state, action) => {
const todo = state.items.find(todo => todo.id === action.payload);
if (todo) {
todo.completed = !todo.completed;
}
},
deleteTodo: (state, action) => {
state.items = state.items.filter(todo => todo.id !== action.payload);
}
}
});
export const { addTodo, toggleTodo, deleteTodo } = todosSlice.actions;
// Selectors
export const selectAllTodos = (state) => state.todos.items;
export const selectActiveTodos = (state) =>
state.todos.items.filter(todo => !todo.completed);
export const selectCompletedTodos = (state) =>
state.todos.items.filter(todo => todo.completed);
export default todosSlice.reducer;
2. Configure the Store
// app/store.js
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from '../features/counter/counterSlice';
import todosReducer from '../features/todos/todosSlice';
const store = configureStore({
reducer: {
counter: counterReducer,
todos: todosReducer
}
});
export default store;
3. Connect to React
// index.js
import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import App from './App';
import store from './app/store';
ReactDOM.render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById('root')
);
// Counter Component
import React from 'react';
import { useSelector, useDispatch } from 'react-redux';
import {
increment,
decrement,
incrementByAmount,
selectCount
} from './counterSlice';
export function Counter() {
const count = useSelector(selectCount);
const dispatch = useDispatch();
const [incrementAmount, setIncrementAmount] = React.useState('2');
return (
<div>
<div>
<button onClick={() => dispatch(decrement())}>-</button>
<span>{count}</span>
<button onClick={() => dispatch(increment())}>+</button>
</div>
<div>
<input
value={incrementAmount}
onChange={e => setIncrementAmount(e.target.value)}
/>
<button
onClick={() => dispatch(incrementByAmount(Number(incrementAmount) || 0))}
>
Add Amount
</button>
</div>
</div>
);
}
Redux Toolkit's Other Features
createSelector and Reselect
Redux Toolkit re-exports the createSelector function from the Reselect library, which helps create memoized selector functions:
import { createSelector } from '@reduxjs/toolkit';
// Basic selectors
const selectTodos = state => state.todos.items;
const selectFilter = state => state.todos.filter;
// Memoized selector that combines other selectors
const selectFilteredTodos = createSelector(
[selectTodos, selectFilter],
(todos, filter) => {
switch (filter) {
case 'completed':
return todos.filter(todo => todo.completed);
case 'active':
return todos.filter(todo => !todo.completed);
default:
return todos;
}
}
);
Selectors created with createSelector only recalculate when their inputs change, making them efficient for derived data.
createEntityAdapter
createEntityAdapter provides a standardized way to store and manage "normalized" data in your Redux store:
import { createEntityAdapter, createSlice } from '@reduxjs/toolkit';
// Create an adapter for a specific entity type
const usersAdapter = createEntityAdapter({
// Optional: Specify how to get the ID from entities
selectId: (user) => user.userId,
// Optional: Specify how to sort entities
sortComparer: (a, b) => a.name.localeCompare(b.name)
});
// The adapter provides initial state and reducer functions
const initialState = usersAdapter.getInitialState({
// You can add additional state properties
status: 'idle',
error: null
});
const usersSlice = createSlice({
name: 'users',
initialState,
reducers: {
// Use adapter methods in reducers
userAdded: usersAdapter.addOne,
usersReceived: usersAdapter.setAll,
userUpdated: usersAdapter.updateOne,
userRemoved: usersAdapter.removeOne
}
});
// The adapter also provides selectors
export const {
selectAll: selectAllUsers,
selectById: selectUserById,
selectIds: selectUserIds
} = usersAdapter.getSelectors(state => state.users);
createEntityAdapter is perfect for collections of items like users, products, or posts, where you need efficient CRUD operations and lookups by ID.
Best Practices with Redux Toolkit
Organize by Feature
Structure your Redux code by feature, not by type. Each feature folder should contain its slice file and related components:
src/
features/
users/
usersSlice.js
UsersList.js
UserDetails.js
posts/
postsSlice.js
PostsList.js
PostForm.js
comments/
commentsSlice.js
CommentsList.js
app/
store.js
rootReducer.js
Use RTK Query for Data Fetching
For API calls, consider using RTK Query (part of Redux Toolkit) which handles caching, loading states, and more:
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
export const api = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({ baseUrl: 'https://api.example.com/' }),
endpoints: (builder) => ({
getUsers: builder.query({
query: () => 'users'
}),
getUserById: builder.query({
query: (id) => `users/${id}`
}),
createUser: builder.mutation({
query: (newUser) => ({
url: 'users',
method: 'POST',
body: newUser
})
})
})
});
Avoid Storing Derived Data
Use selectors to derive data instead of storing it in the state. This keeps the state minimal and prevents synchronization issues.
Use Action Payload Creators
Take advantage of prepare callbacks for complex action payloads to keep your components cleaner.
Use TypeScript for Type Safety
Redux Toolkit works extremely well with TypeScript, providing excellent type inference:
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface CounterState {
value: number;
status: 'idle' | 'loading' | 'failed';
}
const initialState: CounterState = {
value: 0,
status: 'idle'
};
const counterSlice = createSlice({
name: 'counter',
initialState,
reducers: {
increment: (state) => {
state.value += 1;
},
decrement: (state) => {
state.value -= 1;
},
incrementByAmount: (state, action: PayloadAction<number>) => {
state.value += action.payload;
}
}
});
Practice Activity
Convert Todo App to Redux Toolkit
Take the Todo List application from the previous lecture's practice activity and convert it to use Redux Toolkit.
- Create a todosSlice using createSlice
- Add reducers for adding, toggling, and removing todos
- Create a filtersSlice for filtering todos
- Configure the store with configureStore
- Create selectors for filtering todos
Start with this skeleton code:
// features/todos/todosSlice.js
import { createSlice } from '@reduxjs/toolkit';
// TODO: Create a todos slice with addTodo, toggleTodo, and removeTodo reducers
// features/filters/filtersSlice.js
import { createSlice } from '@reduxjs/toolkit';
// TODO: Create a filters slice with setFilter reducer
// app/store.js
import { configureStore } from '@reduxjs/toolkit';
// TODO: Import reducers and configure store
// features/todos/todosSelectors.js
import { createSelector } from '@reduxjs/toolkit';
// TODO: Create selectors for filtered todos
Bonus Challenge: Add a "clearCompleted" action and implement optimistic updates for toggle actions.
Key Takeaways
- Redux Toolkit simplifies Redux development by reducing boilerplate and enforcing best practices
- configureStore provides simplified store setup with good defaults
- createSlice is the primary API for creating actions and reducers
- RTK uses Immer internally for easy immutable updates
- Organize your code by feature instead of by type
- Use selectors for derived data and createSelector for memoization
- Consider using createEntityAdapter for collections of items