Loading...

React State Management with Redux Toolkit

Web Development January 15, 2026


# React State Management with Redux Toolkit

Redux Toolkit (RTK) is the modern, opinionated way to write Redux logic. It simplifies store setup, reduces boilerplate, and includes powerful utilities for common use cases.

## Setting Up Redux Toolkit

### Installation and Store Configuration
```bash
npm install @reduxjs/toolkit react-redux
```

```javascript
// store/index.js
import { configureStore } from '@reduxjs/toolkit'
import counterReducer from './counterSlice'
import userReducer from './userSlice'
import apiSlice from './apiSlice'

export const store = configureStore({
reducer: {
counter: counterReducer,
user: userReducer,
api: apiSlice.reducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: {
ignoredActions: ['persist/PERSIST'],
},
}).concat(apiSlice.middleware),
})

export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch
```

## Creating Slices

### Basic Slice with Reducers
```javascript
// store/counterSlice.js
import { createSlice } from '@reduxjs/toolkit'

const initialState = {
value: 0,
status: 'idle'
}

export const counterSlice = createSlice({
name: 'counter',
initialState,
reducers: {
increment: (state) => {
state.value += 1
},
decrement: (state) => {
state.value -= 1
},
incrementByAmount: (state, action) => {
state.value += action.payload
},
reset: (state) => {
state.value = 0
state.status = 'idle'
}
},
})

export const { increment, decrement, incrementByAmount, reset } = counterSlice.actions
export default counterSlice.reducer
```

### Complex State with Nested Objects
```javascript
// store/userSlice.js
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'

export const fetchUserProfile = createAsyncThunk(
'user/fetchProfile',
async (userId, { rejectWithValue }) => {
try {
const response = await fetch(`/api/users/${userId}`)
if (!response.ok) {
throw new Error('Failed to fetch user')
}
return await response.json()
} catch (error) {
return rejectWithValue(error.message)
}
}
)

const userSlice = createSlice({
name: 'user',
initialState: {
profile: null,
preferences: {
theme: 'light',
notifications: true,
language: 'en'
},
loading: false,
error: null
},
reducers: {
updatePreferences: (state, action) => {
state.preferences = { ...state.preferences, ...action.payload }
},
clearError: (state) => {
state.error = null
},
logout: (state) => {
state.profile = null
state.preferences = {
theme: 'light',
notifications: true,
language: 'en'
}
}
},
extraReducers: (builder) => {
builder
.addCase(fetchUserProfile.pending, (state) => {
state.loading = true
state.error = null
})
.addCase(fetchUserProfile.fulfilled, (state, action) => {
state.loading = false
state.profile = action.payload
})
.addCase(fetchUserProfile.rejected, (state, action) => {
state.loading = false
state.error = action.payload
})
}
})

export const { updatePreferences, clearError, logout } = userSlice.actions
export default userSlice.reducer
```

## Async Thunks for API Calls

### Advanced Async Thunk Patterns
```javascript
// store/postsSlice.js
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'

export const fetchPosts = createAsyncThunk(
'posts/fetchPosts',
async ({ page = 1, limit = 10 }, { getState, rejectWithValue }) => {
try {
const { user } = getState()
const response = await fetch(`/api/posts?page=${page}&limit=${limit}`, {
headers: {
'Authorization': `Bearer ${user.token}`
}
})

if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}

const data = await response.json()
return { posts: data.posts, totalPages: data.totalPages, currentPage: page }
} catch (error) {
return rejectWithValue(error.message)
}
}
)

export const createPost = createAsyncThunk(
'posts/createPost',
async (postData, { dispatch, rejectWithValue }) => {
try {
const response = await fetch('/api/posts', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(postData)
})

const newPost = await response.json()

// Optionally refresh the posts list
dispatch(fetchPosts({ page: 1 }))

return newPost
} catch (error) {
return rejectWithValue(error.message)
}
}
)

const postsSlice = createSlice({
name: 'posts',
initialState: {
items: [],
currentPage: 1,
totalPages: 1,
loading: false,
error: null,
creating: false
},
reducers: {
clearPosts: (state) => {
state.items = []
state.currentPage = 1
state.totalPages = 1
},
updatePost: (state, action) => {
const index = state.items.findIndex(post => post.id === action.payload.id)
if (index !== -1) {
state.items[index] = { ...state.items[index], ...action.payload }
}
}
},
extraReducers: (builder) => {
builder
// Fetch posts
.addCase(fetchPosts.pending, (state) => {
state.loading = true
})
.addCase(fetchPosts.fulfilled, (state, action) => {
state.loading = false
state.items = action.payload.posts
state.currentPage = action.payload.currentPage
state.totalPages = action.payload.totalPages
})
.addCase(fetchPosts.rejected, (state, action) => {
state.loading = false
state.error = action.payload
})
// Create post
.addCase(createPost.pending, (state) => {
state.creating = true
})
.addCase(createPost.fulfilled, (state, action) => {
state.creating = false
state.items.unshift(action.payload)
})
.addCase(createPost.rejected, (state, action) => {
state.creating = false
state.error = action.payload
})
}
})

export const { clearPosts, updatePost } = postsSlice.actions
export default postsSlice.reducer
```

## RTK Query for Advanced Data Fetching

### API Slice Configuration
```javascript
// store/apiSlice.js
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'

export const apiSlice = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({
baseUrl: '/api',
prepareHeaders: (headers, { getState }) => {
const token = getState().user.token
if (token) {
headers.set('authorization', `Bearer ${token}`)
}
return headers
},
}),
tagTypes: ['Post', 'User', 'Comment'],
endpoints: (builder) => ({
getPosts: builder.query({
query: ({ page = 1, limit = 10 } = {}) => `posts?page=${page}&limit=${limit}`,
providesTags: (result) =>
result
? [
...result.posts.map(({ id }) => ({ type: 'Post', id })),
{ type: 'Post', id: 'LIST' },
]
: [{ type: 'Post', id: 'LIST' }],
}),
getPost: builder.query({
query: (id) => `posts/${id}`,
providesTags: (result, error, id) => [{ type: 'Post', id }],
}),
createPost: builder.mutation({
query: (newPost) => ({
url: 'posts',
method: 'POST',
body: newPost,
}),
invalidatesTags: [{ type: 'Post', id: 'LIST' }],
}),
updatePost: builder.mutation({
query: ({ id, ...patch }) => ({
url: `posts/${id}`,
method: 'PATCH',
body: patch,
}),
invalidatesTags: (result, error, { id }) => [{ type: 'Post', id }],
}),
deletePost: builder.mutation({
query: (id) => ({
url: `posts/${id}`,
method: 'DELETE',
}),
invalidatesTags: (result, error, id) => [
{ type: 'Post', id },
{ type: 'Post', id: 'LIST' },
],
}),
}),
})

export const {
useGetPostsQuery,
useGetPostQuery,
useCreatePostMutation,
useUpdatePostMutation,
useDeletePostMutation,
} = apiSlice
```

## Using Redux in React Components

### Typed Hooks for TypeScript
```typescript
// hooks/redux.ts
import { useDispatch, useSelector, TypedUseSelectorHook } from 'react-redux'
import type { RootState, AppDispatch } from '../store'

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

### Component Integration
```jsx
// components/PostsList.jsx
import React, { useEffect } from 'react'
import { useAppSelector, useAppDispatch } from '../hooks/redux'
import { useGetPostsQuery } from '../store/apiSlice'
import { fetchPosts } from '../store/postsSlice'

const PostsList = () => {
const dispatch = useAppDispatch()
const { currentPage } = useAppSelector(state => state.posts)

// Using RTK Query
const {
data: posts,
error,
isLoading,
refetch
} = useGetPostsQuery({ page: currentPage, limit: 10 })

const handlePageChange = (newPage) => {
refetch({ page: newPage, limit: 10 })
}

if (isLoading) return <div>Loading...</div>
if (error) return <div>Error: {error.message}</div>

return (
<div>
{posts?.posts.map(post => (
<div key={post.id}>
<h3>{post.title}</h3>
<p>{post.excerpt}</p>
</div>
))}

<Pagination
currentPage={currentPage}
totalPages={posts?.totalPages}
onPageChange={handlePageChange}
/>
</div>
)
}

export default PostsList
```

### Form Handling with Redux
```jsx
// components/PostForm.jsx
import React, { useState } from 'react'
import { useCreatePostMutation } from '../store/apiSlice'
import { useAppSelector } from '../hooks/redux'

const PostForm = () => {
const [formData, setFormData] = useState({
title: '',
content: '',
category: ''
})

const [createPost, { isLoading, error }] = useCreatePostMutation()
const user = useAppSelector(state => state.user.profile)

const handleSubmit = async (e) => {
e.preventDefault()
try {
await createPost({
...formData,
authorId: user.id
}).unwrap()

// Reset form
setFormData({ title: '', content: '', category: '' })

// Show success message
alert('Post created successfully!')
} catch (error) {
console.error('Failed to create post:', error)
}
}

return (
<form onSubmit={handleSubmit}>
<input
type="text"
placeholder="Title"
value={formData.title}
onChange={(e) => setFormData(prev => ({ ...prev, title: e.target.value }))}
required
/>

<textarea
placeholder="Content"
value={formData.content}
onChange={(e) => setFormData(prev => ({ ...prev, content: e.target.value }))}
required
/>

<select
value={formData.category}
onChange={(e) => setFormData(prev => ({ ...prev, category: e.target.value }))}
required
>
<option value="">Select Category</option>
<option value="tech">Technology</option>
<option value="lifestyle">Lifestyle</option>
</select>

<button type="submit" disabled={isLoading}>
{isLoading ? 'Creating...' : 'Create Post'}
</button>

{error && <div className="error">{error.message}</div>}
</form>
)
}

export default PostForm
```

## Best Practices

### 1. Normalize State Structure
```javascript
// Good: Normalized state
const initialState = {
posts: {
byId: {},
allIds: []
},
users: {
byId: {},
allIds: []
}
}

// Avoid: Nested/denormalized state
const badState = {
posts: [
{
id: 1,
title: 'Post 1',
author: { id: 1, name: 'John' }, // Duplicated data
comments: [...]
}
]
}
```

### 2. Use Selectors for Computed Values
```javascript
// store/selectors.js
import { createSelector } from '@reduxjs/toolkit'

export const selectAllPosts = (state) => state.posts.items

export const selectPostsByCategory = createSelector(
[selectAllPosts, (state, category) => category],
(posts, category) => posts.filter(post => post.category === category)
)

export const selectPostsStats = createSelector(
[selectAllPosts],
(posts) => ({
total: posts.length,
published: posts.filter(p => p.status === 'published').length,
draft: posts.filter(p => p.status === 'draft').length
})
)
```

### 3. Error Handling Patterns
```javascript
const handleAsyncError = (builder, asyncThunk) => {
builder
.addCase(asyncThunk.pending, (state) => {
state.loading = true
state.error = null
})
.addCase(asyncThunk.fulfilled, (state, action) => {
state.loading = false
// Handle success
})
.addCase(asyncThunk.rejected, (state, action) => {
state.loading = false
state.error = {
message: action.payload || action.error.message,
code: action.error.code,
timestamp: Date.now()
}
})
}
```

## Conclusion

Redux Toolkit dramatically simplifies Redux usage while maintaining all its benefits. By using RTK Query, you can eliminate most data fetching boilerplate and get powerful caching and synchronization features out of the box.

Key takeaways:
- Use `createSlice` for simple state management
- Leverage `createAsyncThunk` for complex async operations
- Implement RTK Query for advanced data fetching needs
- Always normalize your state structure
- Use selectors for computed values
- Handle errors consistently across your application

With these patterns, you can build scalable, maintainable React applications with predictable state management.