React Redux Toolkit: Simplifying State Management Guide

Learn how Redux Toolkit simplifies React state management by reducing boilerplate code and providing powerful utilities for modern applications.

React and Redux Toolkit: Simplifying State Management

Introduction

State management is one of the most critical aspects of building modern React applications. As applications grow in complexity, managing state across multiple components becomes increasingly challenging. While React's built-in state management works well for simple scenarios, larger applications often require more sophisticated solutions. Redux has long been the go-to choice for predictable state management in JavaScript applications, but its traditional setup often involved significant boilerplate code and complexity.

Enter Redux Toolkit (RTK) – the official, opinionated, batteries-included toolset for efficient Redux development. Redux Toolkit addresses the common complaints about Redux being "too complex" or requiring "too much boilerplate code" by providing utilities that simplify the most common Redux use cases. It includes utilities to simplify store setup, create reducers, immutable update logic, and even create entire "slices" of state at once.

In this comprehensive guide, we'll explore Redux Toolkit's powerful features, learn how to set it up in React applications, understand slices and thunks, and build practical example projects that demonstrate real-world usage patterns.

What is Redux Toolkit?

Redux Toolkit is the official, recommended way to write Redux logic. It was originally created to help address three common concerns about Redux:

1. Configuring a Redux store is too complicated 2. I have to add a lot of packages to get Redux to do anything useful 3. Redux requires too much boilerplate code

Redux Toolkit builds on top of Redux core and includes several utility functions that wrap around Redux core to provide a better developer experience. It includes several packages that are commonly used with Redux, such as Immer for immutable updates and Redux Thunk for async logic.

Key Benefits of Redux Toolkit

- Simplified Store Setup: Configure your Redux store with good defaults in just a few lines - Reduced Boilerplate: Write cleaner, more concise Redux logic - Built-in Best Practices: Includes utilities that follow Redux best practices by default - Better Developer Experience: Improved debugging and development tools - Type Safety: Excellent TypeScript support out of the box

Core Features of Redux Toolkit

1. configureStore()

The configureStore() function wraps around Redux's createStore() to provide simplified configuration options and good defaults. It automatically sets up the Redux DevTools Extension and includes commonly used middleware.

`javascript import { configureStore } from '@reduxjs/toolkit' import counterReducer from './features/counter/counterSlice'

export const store = configureStore({ reducer: { counter: counterReducer, }, }) `

2. createSlice()

The createSlice() function accepts an object of reducer functions, a slice name, and an initial state value, and automatically generates a slice reducer with corresponding action creators and action types.

`javascript import { createSlice } from '@reduxjs/toolkit'

const counterSlice = createSlice({ name: 'counter', initialState: { value: 0, }, 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 export default counterSlice.reducer `

3. createAsyncThunk()

The createAsyncThunk() function simplifies the process of handling async logic and dispatching actions based on Promise lifecycle states.

`javascript import { createAsyncThunk, createSlice } from '@reduxjs/toolkit' import { userAPI } from './userAPI'

export const fetchUserById = createAsyncThunk( 'users/fetchById', async (userId) => { const response = await userAPI.fetchById(userId) return response.data } )

const usersSlice = createSlice({ name: 'users', initialState: { entities: [], loading: 'idle', }, reducers: { // standard reducer logic }, extraReducers: (builder) => { builder .addCase(fetchUserById.pending, (state) => { state.loading = 'pending' }) .addCase(fetchUserById.fulfilled, (state, action) => { state.loading = 'idle' state.entities.push(action.payload) }) .addCase(fetchUserById.rejected, (state) => { state.loading = 'idle' }) }, }) `

4. createEntityAdapter()

The createEntityAdapter() function provides a standardized way to store normalized data in your state, along with pre-built reducers for common operations.

`javascript import { createEntityAdapter, createSlice } from '@reduxjs/toolkit'

const usersAdapter = createEntityAdapter({ selectId: (user) => user.id, sortComparer: (a, b) => a.name.localeCompare(b.name), })

const usersSlice = createSlice({ name: 'users', initialState: usersAdapter.getInitialState(), reducers: { addUser: usersAdapter.addOne, updateUser: usersAdapter.updateOne, removeUser: usersAdapter.removeOne, }, }) `

Setting Up Redux Toolkit in a React Application

Let's walk through setting up Redux Toolkit in a React application from scratch.

Installation

First, install the necessary packages:

`bash npm install @reduxjs/toolkit react-redux `

Basic Project Structure

Create a well-organized folder structure for your Redux logic:

` src/ ├── app/ │ └── store.js ├── features/ │ ├── counter/ │ │ └── counterSlice.js │ └── todos/ │ └── todosSlice.js └── components/ ├── Counter.js └── TodoList.js `

Store Configuration

Create your Redux store in src/app/store.js:

`javascript import { configureStore } from '@reduxjs/toolkit' import counterReducer from '../features/counter/counterSlice' import todosReducer from '../features/todos/todosSlice'

export const store = configureStore({ reducer: { counter: counterReducer, todos: todosReducer, }, middleware: (getDefaultMiddleware) => getDefaultMiddleware({ serializableCheck: { ignoredActions: ['persist/PERSIST'], }, }), })

export type RootState = ReturnType export type AppDispatch = typeof store.dispatch `

Provider Setup

Wrap your app with the Redux Provider in src/index.js:

`javascript import React from 'react' import ReactDOM from 'react-dom/client' import { Provider } from 'react-redux' import { store } from './app/store' import App from './App'

const root = ReactDOM.createRoot(document.getElementById('root')) root.render( ) `

Understanding Slices in Redux Toolkit

Slices are the heart of Redux Toolkit. A slice is a collection of Redux reducer logic and actions for a single feature of your app. The createSlice() function lets you define a slice in one place, automatically generating action creators and action types based on the names of the reducer functions you provide.

Anatomy of a Slice

`javascript import { createSlice } from '@reduxjs/toolkit'

const initialState = { items: [], status: 'idle', error: null, }

const todosSlice = createSlice({ name: 'todos', // Used as prefix for generated action types initialState, reducers: { // Reducer functions todoAdded: (state, action) => { state.items.push({ id: action.payload.id, text: action.payload.text, completed: false, }) }, todoToggled: (state, action) => { const todo = state.items.find(todo => todo.id === action.payload) if (todo) { todo.completed = !todo.completed } }, todoDeleted: (state, action) => { const index = state.items.findIndex(todo => todo.id === action.payload) if (index !== -1) { state.items.splice(index, 1) } }, todosCleared: (state) => { state.items = [] }, }, })

// Action creators are generated for each case reducer function export const { todoAdded, todoToggled, todoDeleted, todosCleared } = todosSlice.actions

export default todosSlice.reducer `

Immer Integration

One of the most powerful features of Redux Toolkit is its integration with Immer. Immer allows you to write "mutative" logic that is actually immutable under the hood. This makes Redux reducers much easier to read and write.

`javascript // Traditional Redux (immutable updates) const todosReducer = (state = initialState, action) => { switch (action.type) { case 'todos/todoAdded': return { ...state, items: [ ...state.items, { id: action.payload.id, text: action.payload.text, completed: false, }, ], } default: return state } }

// Redux Toolkit with Immer (appears mutative but is actually immutable) const todosSlice = createSlice({ name: 'todos', initialState, reducers: { todoAdded: (state, action) => { state.items.push({ id: action.payload.id, text: action.payload.text, completed: false, }) }, }, }) `

Preparing Actions

Sometimes you need to customize the action creator. You can do this with the prepare callback:

`javascript const todosSlice = createSlice({ name: 'todos', initialState, reducers: { todoAdded: { reducer: (state, action) => { state.items.push(action.payload) }, prepare: (text) => { return { payload: { id: nanoid(), text, completed: false, createdAt: new Date().toISOString(), }, } }, }, }, }) `

Working with Async Logic and Thunks

Redux Toolkit provides createAsyncThunk() to handle async logic in a standardized way. This function automatically dispatches actions based on the Promise lifecycle.

Basic Async Thunk

`javascript import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'

// Async thunk action creator export const fetchTodos = createAsyncThunk( 'todos/fetchTodos', async (_, { rejectWithValue }) => { try { const response = await fetch('/api/todos') if (!response.ok) { throw new Error('Failed to fetch todos') } return await response.json() } catch (error) { return rejectWithValue(error.message) } } )

export const addTodo = createAsyncThunk( 'todos/addTodo', async (todoText, { rejectWithValue }) => { try { const response = await fetch('/api/todos', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ text: todoText }), }) if (!response.ok) { throw new Error('Failed to add todo') } return await response.json() } catch (error) { return rejectWithValue(error.message) } } )

const todosSlice = createSlice({ name: 'todos', initialState: { items: [], status: 'idle', // 'idle' | 'loading' | 'succeeded' | 'failed' error: null, }, reducers: { // Synchronous reducers }, extraReducers: (builder) => { builder // Fetch todos cases .addCase(fetchTodos.pending, (state) => { state.status = 'loading' }) .addCase(fetchTodos.fulfilled, (state, action) => { state.status = 'succeeded' state.items = action.payload }) .addCase(fetchTodos.rejected, (state, action) => { state.status = 'failed' state.error = action.payload }) // Add todo cases .addCase(addTodo.fulfilled, (state, action) => { state.items.push(action.payload) }) }, }) `

Using Thunks with Additional Arguments

You can pass additional arguments to thunks using the extraArgument option:

`javascript // Configure store with extra argument export const store = configureStore({ reducer: { todos: todosSlice.reducer, }, middleware: (getDefaultMiddleware) => getDefaultMiddleware({ thunk: { extraArgument: { api: apiService, router: history, }, }, }), })

// Use extra argument in thunk export const fetchTodos = createAsyncThunk( 'todos/fetchTodos', async (userId, { extra, rejectWithValue }) => { try { const response = await extra.api.get(/users/${userId}/todos) return response.data } catch (error) { return rejectWithValue(error.message) } } ) `

Integration with React Components

Now let's see how to connect Redux Toolkit to React components using the React-Redux hooks.

Typed Hooks (TypeScript)

First, create typed versions of the hooks:

`typescript // src/app/hooks.ts import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux' import type { RootState, AppDispatch } from './store'

export const useAppDispatch = () => useDispatch() export const useAppSelector: TypedUseSelectorHook = useSelector `

Counter Component Example

`javascript import React from 'react' import { useSelector, useDispatch } from 'react-redux' import { increment, decrement, incrementByAmount } from '../features/counter/counterSlice'

const Counter = () => { const count = useSelector((state) => state.counter.value) const dispatch = useDispatch()

return (

{count}
) }

export default Counter `

Todo List Component with Async Actions

`javascript import React, { useEffect, useState } from 'react' import { useSelector, useDispatch } from 'react-redux' import { fetchTodos, addTodo, todoToggled, todoDeleted, } from '../features/todos/todosSlice'

const TodoList = () => { const dispatch = useDispatch() const todos = useSelector((state) => state.todos.items) const status = useSelector((state) => state.todos.status) const error = useSelector((state) => state.todos.error) const [newTodoText, setNewTodoText] = useState('')

useEffect(() => { if (status === 'idle') { dispatch(fetchTodos()) } }, [status, dispatch])

const handleSubmit = (e) => { e.preventDefault() if (newTodoText.trim()) { dispatch(addTodo(newTodoText)) setNewTodoText('') } }

if (status === 'loading') { return

Loading...
}

if (status === 'failed') { return

Error: {error}
}

return (

setNewTodoText(e.target.value)} placeholder="Add a new todo" />
    {todos.map((todo) => (
  • dispatch(todoToggled(todo.id))} /> {todo.text}
  • ))}
) }

export default TodoList `

Example Project 1: E-commerce Shopping Cart

Let's build a complete e-commerce shopping cart application to demonstrate Redux Toolkit in action.

Product Slice

`javascript // src/features/products/productsSlice.js import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'

export const fetchProducts = createAsyncThunk( 'products/fetchProducts', async () => { const response = await fetch('/api/products') return response.json() } )

const productsSlice = createSlice({ name: 'products', initialState: { items: [], status: 'idle', error: null, }, reducers: {}, extraReducers: (builder) => { builder .addCase(fetchProducts.pending, (state) => { state.status = 'loading' }) .addCase(fetchProducts.fulfilled, (state, action) => { state.status = 'succeeded' state.items = action.payload }) .addCase(fetchProducts.rejected, (state, action) => { state.status = 'failed' state.error = action.error.message }) }, })

export default productsSlice.reducer `

Cart Slice

`javascript // src/features/cart/cartSlice.js import { createSlice } from '@reduxjs/toolkit'

const cartSlice = createSlice({ name: 'cart', initialState: { items: [], total: 0, }, reducers: { addToCart: (state, action) => { const { id, name, price } = action.payload const existingItem = state.items.find((item) => item.id === id) if (existingItem) { existingItem.quantity += 1 } else { state.items.push({ id, name, price, quantity: 1 }) } state.total += price }, removeFromCart: (state, action) => { const id = action.payload const existingItem = state.items.find((item) => item.id === id) if (existingItem) { state.total -= existingItem.price * existingItem.quantity state.items = state.items.filter((item) => item.id !== id) } }, updateQuantity: (state, action) => { const { id, quantity } = action.payload const existingItem = state.items.find((item) => item.id === id) if (existingItem) { const difference = quantity - existingItem.quantity state.total += difference * existingItem.price existingItem.quantity = quantity if (quantity <= 0) { state.items = state.items.filter((item) => item.id !== id) } } }, clearCart: (state) => { state.items = [] state.total = 0 }, }, })

export const { addToCart, removeFromCart, updateQuantity, clearCart } = cartSlice.actions

// Selectors export const selectCartItems = (state) => state.cart.items export const selectCartTotal = (state) => state.cart.total export const selectCartItemCount = (state) => state.cart.items.reduce((total, item) => total + item.quantity, 0)

export default cartSlice.reducer `

Product List Component

`javascript // src/components/ProductList.js import React, { useEffect } from 'react' import { useSelector, useDispatch } from 'react-redux' import { fetchProducts } from '../features/products/productsSlice' import { addToCart } from '../features/cart/cartSlice'

const ProductList = () => { const dispatch = useDispatch() const products = useSelector((state) => state.products.items) const status = useSelector((state) => state.products.status)

useEffect(() => { if (status === 'idle') { dispatch(fetchProducts()) } }, [status, dispatch])

const handleAddToCart = (product) => { dispatch(addToCart({ id: product.id, name: product.name, price: product.price, })) }

if (status === 'loading') return

Loading products...
if (status === 'failed') return
Error loading products

return (

{products.map((product) => (
{product.name}

{product.name}

${product.price}

))}
) }

export default ProductList `

Cart Component

`javascript // src/components/Cart.js import React from 'react' import { useSelector, useDispatch } from 'react-redux' import { selectCartItems, selectCartTotal, selectCartItemCount, removeFromCart, updateQuantity, clearCart, } from '../features/cart/cartSlice'

const Cart = () => { const dispatch = useDispatch() const cartItems = useSelector(selectCartItems) const total = useSelector(selectCartTotal) const itemCount = useSelector(selectCartItemCount)

const handleQuantityChange = (id, newQuantity) => { dispatch(updateQuantity({ id, quantity: parseInt(newQuantity) })) }

const handleRemoveItem = (id) => { dispatch(removeFromCart(id)) }

const handleClearCart = () => { dispatch(clearCart()) }

if (cartItems.length === 0) { return

Your cart is empty
}

return (

Shopping Cart ({itemCount} items)

{cartItems.map((item) => (
{item.name} ${item.price} handleQuantityChange(item.id, e.target.value)} /> ${(item.price * item.quantity).toFixed(2)}
))}
Total: ${total.toFixed(2)}
) }

export default Cart `

Example Project 2: Blog Application with User Authentication

Let's create a more complex example with user authentication and blog posts.

Auth Slice

`javascript // src/features/auth/authSlice.js import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'

export const loginUser = createAsyncThunk( 'auth/loginUser', async ({ email, password }, { rejectWithValue }) => { try { const response = await fetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password }), }) if (!response.ok) { const error = await response.json() return rejectWithValue(error.message) } const data = await response.json() localStorage.setItem('token', data.token) return data.user } catch (error) { return rejectWithValue(error.message) } } )

export const logoutUser = createAsyncThunk('auth/logoutUser', async () => { localStorage.removeItem('token') return null })

export const checkAuthStatus = createAsyncThunk( 'auth/checkAuthStatus', async (_, { rejectWithValue }) => { try { const token = localStorage.getItem('token') if (!token) return null const response = await fetch('/api/auth/me', { headers: { Authorization: Bearer ${token} }, }) if (!response.ok) { localStorage.removeItem('token') return null } return await response.json() } catch (error) { return rejectWithValue(error.message) } } )

const authSlice = createSlice({ name: 'auth', initialState: { user: null, token: localStorage.getItem('token'), isLoading: false, error: null, }, reducers: { clearError: (state) => { state.error = null }, }, extraReducers: (builder) => { builder // Login cases .addCase(loginUser.pending, (state) => { state.isLoading = true state.error = null }) .addCase(loginUser.fulfilled, (state, action) => { state.isLoading = false state.user = action.payload state.token = localStorage.getItem('token') }) .addCase(loginUser.rejected, (state, action) => { state.isLoading = false state.error = action.payload }) // Logout cases .addCase(logoutUser.fulfilled, (state) => { state.user = null state.token = null }) // Check auth status cases .addCase(checkAuthStatus.fulfilled, (state, action) => { state.user = action.payload }) }, })

export const { clearError } = authSlice.actions

// Selectors export const selectCurrentUser = (state) => state.auth.user export const selectIsAuthenticated = (state) => !!state.auth.user export const selectAuthLoading = (state) => state.auth.isLoading export const selectAuthError = (state) => state.auth.error

export default authSlice.reducer `

Posts Slice with Entity Adapter

`javascript // src/features/posts/postsSlice.js import { createSlice, createAsyncThunk, createEntityAdapter, } from '@reduxjs/toolkit'

const postsAdapter = createEntityAdapter({ sortComparer: (a, b) => b.createdAt.localeCompare(a.createdAt), })

export const fetchPosts = createAsyncThunk('posts/fetchPosts', async () => { const response = await fetch('/api/posts') return response.json() })

export const addPost = createAsyncThunk( 'posts/addPost', async (postData, { getState }) => { const token = getState().auth.token const response = await fetch('/api/posts', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: Bearer ${token}, }, body: JSON.stringify(postData), }) return response.json() } )

export const updatePost = createAsyncThunk( 'posts/updatePost', async ({ id, ...postData }, { getState }) => { const token = getState().auth.token const response = await fetch(/api/posts/${id}, { method: 'PUT', headers: { 'Content-Type': 'application/json', Authorization: Bearer ${token}, }, body: JSON.stringify(postData), }) return response.json() } )

export const deletePost = createAsyncThunk( 'posts/deletePost', async (postId, { getState }) => { const token = getState().auth.token await fetch(/api/posts/${postId}, { method: 'DELETE', headers: { Authorization: Bearer ${token} }, }) return postId } )

const postsSlice = createSlice({ name: 'posts', initialState: postsAdapter.getInitialState({ status: 'idle', error: null, }), reducers: {}, extraReducers: (builder) => { builder .addCase(fetchPosts.pending, (state) => { state.status = 'loading' }) .addCase(fetchPosts.fulfilled, (state, action) => { state.status = 'succeeded' postsAdapter.setAll(state, action.payload) }) .addCase(fetchPosts.rejected, (state, action) => { state.status = 'failed' state.error = action.error.message }) .addCase(addPost.fulfilled, postsAdapter.addOne) .addCase(updatePost.fulfilled, (state, action) => { postsAdapter.updateOne(state, { id: action.payload.id, changes: action.payload, }) }) .addCase(deletePost.fulfilled, postsAdapter.removeOne) }, })

// Export the customized selectors for this adapter using getSelectors export const { selectAll: selectAllPosts, selectById: selectPostById, selectIds: selectPostIds, } = postsAdapter.getSelectors((state) => state.posts)

export default postsSlice.reducer `

Blog Component

`javascript // src/components/Blog.js import React, { useEffect, useState } from 'react' import { useSelector, useDispatch } from 'react-redux' import { fetchPosts, addPost, deletePost, selectAllPosts, } from '../features/posts/postsSlice' import { selectCurrentUser, selectIsAuthenticated, } from '../features/auth/authSlice'

const Blog = () => { const dispatch = useDispatch() const posts = useSelector(selectAllPosts) const currentUser = useSelector(selectCurrentUser) const isAuthenticated = useSelector(selectIsAuthenticated) const postsStatus = useSelector((state) => state.posts.status) const [newPost, setNewPost] = useState({ title: '', content: '' }) const [showForm, setShowForm] = useState(false)

useEffect(() => { if (postsStatus === 'idle') { dispatch(fetchPosts()) } }, [postsStatus, dispatch])

const handleSubmit = (e) => { e.preventDefault() if (newPost.title && newPost.content) { dispatch(addPost(newPost)) setNewPost({ title: '', content: '' }) setShowForm(false) } }

const handleDelete = (postId) => { if (window.confirm('Are you sure you want to delete this post?')) { dispatch(deletePost(postId)) } }

return (

Blog Posts

{isAuthenticated && (
{!showForm ? ( ) : (
setNewPost({ ...newPost, title: e.target.value }) } />