Managing state across various components in modern web development can quickly become a challenge, especially as your application scales. While React’s built-in state management works for simple use cases, larger applications often require a more sophisticated solution to keep state consistent across multiple components. Redux has long been the go-to state management library for React apps, offering a predictable way to manage state. However, the boilerplate code involved in traditional Redux setups can be overwhelming, leading to the rise of Redux Toolkit (RTK).
In this article, we’ll explore how Redux Toolkit simplifies the process of working with Redux and compare it to Redux Saga, another popular tool for managing side effects in Redux. Both libraries can handle asynchronous actions, but they do so in different ways. We’ll take a look at how each works and which might be the better choice depending on the complexity of your app.
Problems with Traditional Redux (Without Redux Toolkit)
Before Redux Toolkit (RTK), managing state in a Redux application involved significant boilerplate code, complex setup, and difficult maintenance. Redux was a great state management solution, but its complexity often led to problems in large-scale applications. Here’s a breakdown of the key issues:
1. Boilerplate Code
One of the most common complaints with traditional Redux is the amount of boilerplate code required to set up actions and reducers.
- Action Creators: You had to manually define action creators for every action you wanted to dispatch, leading to repetitive and verbose code.
// Action type
const ADD_USER = 'ADD_USER';
// Action creator
const addUser = (user) => {
return {
type: ADD_USER,
payload: user,
};
};
// Reducer
const userReducer = (state = [], action) => {
switch (action.type) {
case ADD_USER:
return [...state, action.payload];
default:
return state;
}
};
- Action Types: You had to define and reference string constants for each action type, which led to action type typos being a common bug source.
- Reducers: Reducers were responsible for handling each action type manually, which led to repetitive logic in each case block of the switch statement.
2. Complex Configuration
Setting up Redux required a series of manual configurations:
- Store setup: You had to manually apply middleware, set up the root reducer, and configure Redux DevTools.
import { createStore, applyMiddleware } from 'redux';
import rootReducer from './reducers';
import thunk from 'redux-thunk';
const store = createStore(
rootReducer,
applyMiddleware(thunk)
);
- Middleware: Handling async actions with middleware like
redux-thunk
required additional setup, and the complexity increased with more advanced middleware likeredux-saga
.
3. Handling Asynchronous Actions
Handling async logic in traditional Redux was complex and verbose:
- Async Action Creators: You had to manually dispatch multiple actions (e.g.,
request
,success
,failure
) to reflect the different states of an async operation (loading, success, error).
const fetchUser = (userId) => {
return dispatch => {
dispatch({ type: 'FETCH_USER_REQUEST' });
fetch(`/api/users/${userId}`)
.then(response => response.json())
.then(data => dispatch({ type: 'FETCH_USER_SUCCESS', payload: data }))
.catch(error => dispatch({ type: 'FETCH_USER_FAILURE', error }));
};
};
- Error Handling: Managing errors in async actions required additional reducers and action types. This often led to boilerplate code for every async action.
4. Immutability and State Updates
- Manual State Updates: In traditional Redux, you were responsible for managing immutability manually. If you directly mutated state inside reducers, it could lead to bugs or performance issues.
const userReducer = (state = [], action) => {
switch (action.type) {
case 'ADD_USER':
state.push(action.payload); // Incorrect, mutation of state
return state;
default:
return state;
}
};
- Immer Library: Without tools like Immer, which is integrated into Redux Toolkit, managing immutability required a deep understanding of immutable operations. You needed to use methods like
Object.assign()
or spread operators to avoid mutating the state directly.
5. Verbose and Repetitive Code
As your app grows, you need to add more actions, reducers, and action creators. This often results in repetitive patterns. For example, managing a collection of users required repeating the same patterns across different slices of the state (e.g., users, posts, comments).
6. Difficult to Scale
- As the complexity of your app increased, the boilerplate and configuration needed to scale Redux grew with it. You needed to manage multiple reducers, combine reducers, and ensure they were correctly handling async actions.
- Feature-specific state management: Managing state in separate slices (like users, posts, or products) became harder because each slice needed its own set of action types, action creators, and reducers.
7. Testing Complexity
Writing tests for Redux logic was cumbersome:
- You had to mock action creators, manually dispatch actions, and verify that reducers returned the correct state.
- Writing tests for async actions (using
redux-thunk
orredux-saga
) added another layer of complexity.
8. Developer Experience
While Redux was powerful, it wasn’t always developer-friendly:
- You had to memorize patterns for managing async actions and middleware.
- Developers often made mistakes with action types, and debugging these issues was hard without proper tooling (though Redux DevTools helped).
What is Redux Toolkit?
Redux Toolkit is the official, recommended library for efficient Redux development. It was created to solve the common pain points of Redux, such as its verbose boilerplate code, lack of best practices, and the need for complex configuration. RTK provides a set of tools and utilities that help developers write Redux logic with less boilerplate and fewer errors.
Key Features of Redux Toolkit
- Simplified Store Setup: With
configureStore
, Redux Toolkit sets up the store with the most common configurations for Redux, including built-in middleware likeredux-thunk
for handling asynchronous actions. createSlice
for Reducers and Actions: Redux Toolkit introducescreateSlice
, a function that generates both actions and reducers in a single call. This reduces the need to write separate action creators and manually handle action types.createAsyncThunk
for Asynchronous Logic: Handling async operations like API calls in Redux has traditionally been complex. RTK simplifies this withcreateAsyncThunk
, which abstracts away manual action dispatching, error handling, and state updates.- Immer Integration: Redux Toolkit uses the Immer library to allow you to write mutable code in reducers, making it easier to manage state updates without worrying about immutability.
- Built-in Redux DevTools: RTK automatically integrates with Redux DevTools, offering an out-of-the-box debugging experience.
Why Choose Redux Toolkit?
- Reduces Boilerplate: RTK eliminates the need to write separate action creators and reducers.
- Simplifies Async Operations: Handling async logic like API calls becomes more manageable with
createAsyncThunk
. - Improved Developer Experience: Out-of-the-box support for debugging and a cleaner syntax make Redux easier to work with.
Redux Toolkit Architecture Setup
When structuring a Redux Toolkit application, it’s important to keep the architecture modular and scalable. Here’s an overview of how to organize your project:
1. File Structure
A good file structure helps to separate concerns and makes your codebase easy to scale. Here’s a typical setup:
/src
/app
store.js # Configures the Redux store
/features
/user
userSlice.js # Redux slice for user-related state
userAPI.js # Optionally, API calls related to user data
/posts
postsSlice.js # Redux slice for post-related state
postsAPI.js # Optionally, API calls related to posts data
/components
/UserProfile.js # A React component that connects to Redux
/PostList.js # Another component that connects to Redux
/services
api.js # Generic API utilities (e.g., fetching data)
/hooks
useUser.js # Custom hooks to interact with the Redux store
usePosts.js # Custom hooks to interact with the Redux store
2. Configuring the Redux Store
Redux Toolkit simplifies store setup with configureStore()
, which integrates Redux DevTools and includes middleware like redux-thunk
.
// src/app/store.js
import { configureStore } from '@reduxjs/toolkit';
import userReducer from '../features/user/userSlice';
import postsReducer from '../features/posts/postsSlice';
const store = configureStore({
reducer: {
user: userReducer,
posts: postsReducer
},
});
export default store;
3. Creating Slices
A slice combines actions and reducers for a particular piece of state. Here’s how we define slices for user and posts data:
// src/features/user/userSlice.js
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
export const fetchUserData = createAsyncThunk(
'user/fetchUserData',
async (userId) => {
const response = await fetch(`/api/users/${userId}`);
return response.json();
}
);
const userSlice = createSlice({
name: 'user',
initialState: { user: null, loading: false, error: null },
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchUserData.pending, (state) => { state.loading = true; })
.addCase(fetchUserData.fulfilled, (state, action) => {
state.loading = false;
state.user = action.payload;
})
.addCase(fetchUserData.rejected, (state, action) => {
state.loading = false;
state.error = action.error.message;
});
}
});
export default userSlice.reducer;
4. Using Redux in Components
You can use useSelector
and useDispatch
hooks from react-redux
to interact with the Redux store in your components.
// src/components/UserProfile.js
import React, { useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { fetchUserData } from '../features/user/userSlice';
function UserProfile({ userId }) {
const dispatch = useDispatch();
const { user, loading, error } = useSelector((state) => state.user);
useEffect(() => {
dispatch(fetchUserData(userId));
}, [dispatch, userId]);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
);
}
export default UserProfile;
5. Custom Hooks (Optional)
To make your components cleaner and more reusable, you can create custom hooks that abstract away logic for interacting with the Redux store.
// src/hooks/useUser.js
import { useDispatch, useSelector } from 'react-redux';
import { fetchUserData } from '../features/user/userSlice';
export const useUser = (userId) => {
const dispatch = useDispatch();
const { user, loading, error } = useSelector((state) => state.user);
const fetchUser = () => dispatch(fetchUserData(userId));
return { user, loading, error, fetchUser };
};
This approach makes components easier to manage, and the logic is reusable across your app.
Redux Toolkit vs. Redux Saga: Which Should You Choose?
Both Redux Toolkit and Redux Saga are powerful tools for handling side effects in Redux applications, but they have different approaches. Let’s compare them based on real-world use cases.
Use Case 1: Simple Async API Call
Suppose you need to fetch user data from an API. Let’s compare how Redux Toolkit and Redux Saga handle this.
Redux Toolkit (with createAsyncThunk
):
export const fetchUserData = createAsyncThunk(
'user/fetchUserData',
async (userId) => {
const response = await fetch(`/api/users/${userId}`);
return response.json();
}
);
With Redux Toolkit, the createAsyncThunk
function handles the async logic, managing pending
, fulfilled
, and rejected
states automatically.
Redux Saga:
import { call, put, takeEvery } from 'redux-saga/effects';function* fetchUserDataSaga(action) {
try {
const response = yield call(fetch, `/api/users/${action.payload}`);
const data = yield response.json();
yield put({ type: 'user/fetchUserData/fulfilled', payload: data });
} catch (error) {
yield put({ type: 'user/fetchUserData/rejected', error: error.message });
}
}function* userSaga() {
yield takeEvery('user/fetchUserData', fetchUserDataSaga);
}
While Redux Saga provides more flexibility, it requires more setup. It excels in scenarios where you have complex async logic, such as multiple sequential or parallel API calls, retries, or cancellations.
Use Case 2: Complex Async Workflows
For more complex scenarios where you need to manage multiple async actions, such as fetching user data followed by posts or comments, Redux Saga is often a better choice due to its generator functions that allow fine-grained control over async flows.
Redux Saga (Complex Workflow):
function* fetchUserAndPostsSaga(action) {
try {
const user = yield call(fetch, `/api/users/${action.payload}`);
const posts = yield call(fetch, `/api/posts/${user.id}`);
yield put({ type: 'user/fetchUserAndPosts/fulfilled', payload: { user, posts } });
} catch (error) {
yield put({ type: 'user/fetchUserAndPosts/rejected', error: error.message });
}
}
In contrast, Redux Toolkit would require manually chaining async thunks or using middleware for such advanced workflows, which could lead to more verbose code.
Which One is More Widely Used?
Redux Toolkit is the dominant choice in the modern JavaScript and React ecosystem due to its:
- Simplicity.
- Efficiency.
- Strong developer experience improvements.
- Official endorsement by the Redux team.
It’s the go-to solution for most projects today, particularly those that need to manage simple to moderate async workflows (e.g., API calls, user authentication, state persistence).
Redux Saga, on the other hand, is still in use but is niche and primarily used for complex systems with challenging side effects, concurrency management, or workflows that go beyond simple async actions.
Conclusion
- Redux Toolkit is far more widely used and is the default choice for most modern applications, offering an easier, more streamlined development experience for managing state and async actions.
- Redux Saga is still valuable for more complex scenarios, but it has a steeper learning curve and is typically reserved for projects where async logic is intricate and needs fine-grained control.
If you’re starting a new React project, Redux Toolkit is likely the best option for most use cases. Use Redux Saga if your app has highly complex side effects or specific concurrency needs that Redux Toolkit can’t easily handle.
About the Author
Joshua Salema is the Practice Lead – Digital at Zimetrics, where he plays a key role in driving the development and implementation of innovative digital solutions. He is highly committed to the use of architectural best practices to ensure the creation of secure, scalable, and efficient applications.