React.js Fundamentals

Async Operations with Redux

18 min Lesson 23 of 40

Async Operations with Redux

Redux Toolkit provides createAsyncThunk for handling asynchronous operations like API calls. It automatically dispatches pending, fulfilled, and rejected actions, making async state management straightforward and predictable.

Understanding createAsyncThunk

createAsyncThunk generates action creators and handles the async lifecycle automatically:

Async Action Lifecycle:
  • pending: Dispatched when async operation starts
  • fulfilled: Dispatched when operation succeeds
  • rejected: Dispatched when operation fails
  • Each state can update different parts of your Redux store
  • Automatically includes error handling and loading states

Creating Async Thunks

Let's build a user profile fetcher with createAsyncThunk:

// store/slices/userSlice.js import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'; // Define the async thunk export const fetchUserById = createAsyncThunk( 'users/fetchById', // Action type prefix async (userId, { rejectWithValue }) => { try { const response = await fetch( `https://api.example.com/users/${userId}` ); if (!response.ok) { throw new Error('Failed to fetch user'); } const data = await response.json(); return data; // Becomes action.payload in fulfilled case } catch (error) { return rejectWithValue(error.message); } } ); // Fetch all users export const fetchUsers = createAsyncThunk( 'users/fetchAll', async (_, { rejectWithValue }) => { try { const response = await fetch('https://api.example.com/users'); if (!response.ok) throw new Error('Failed to fetch users'); return await response.json(); } catch (error) { return rejectWithValue(error.message); } } ); // Update user export const updateUser = createAsyncThunk( 'users/update', async ({ userId, updates }, { rejectWithValue }) => { try { const response = await fetch( `https://api.example.com/users/${userId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updates) } ); if (!response.ok) throw new Error('Failed to update user'); return await response.json(); } catch (error) { return rejectWithValue(error.message); } } ); const initialState = { users: [], currentUser: null, status: 'idle', // 'idle' | 'loading' | 'succeeded' | 'failed' error: null }; const userSlice = createSlice({ name: 'users', initialState, reducers: { clearError: (state) => { state.error = null; }, clearCurrentUser: (state) => { state.currentUser = null; } }, extraReducers: (builder) => { // Fetch single user builder .addCase(fetchUserById.pending, (state) => { state.status = 'loading'; state.error = null; }) .addCase(fetchUserById.fulfilled, (state, action) => { state.status = 'succeeded'; state.currentUser = action.payload; }) .addCase(fetchUserById.rejected, (state, action) => { state.status = 'failed'; state.error = action.payload || 'Something went wrong'; }); // Fetch all users builder .addCase(fetchUsers.pending, (state) => { state.status = 'loading'; }) .addCase(fetchUsers.fulfilled, (state, action) => { state.status = 'succeeded'; state.users = action.payload; }) .addCase(fetchUsers.rejected, (state, action) => { state.status = 'failed'; state.error = action.payload; }); // Update user builder .addCase(updateUser.fulfilled, (state, action) => { const index = state.users.findIndex( user => user.id === action.payload.id ); if (index !== -1) { state.users[index] = action.payload; } if (state.currentUser?.id === action.payload.id) { state.currentUser = action.payload; } }); } }); export const { clearError, clearCurrentUser } = userSlice.actions; export default userSlice.reducer; // Selectors export const selectAllUsers = (state) => state.users.users; export const selectCurrentUser = (state) => state.users.currentUser; export const selectUserStatus = (state) => state.users.status; export const selectUserError = (state) => state.users.error;
ThunkAPI Object: The second parameter of createAsyncThunk provides useful utilities: dispatch (dispatch actions), getState (access Redux state), rejectWithValue (custom error handling), signal (AbortController for cancellation), and extra (custom middleware arguments).

Using Async Thunks in Components

Dispatch thunks just like regular actions and handle loading states:

// components/UserProfile.jsx import { useEffect } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { fetchUserById, selectCurrentUser, selectUserStatus, selectUserError, clearCurrentUser } from '../store/slices/userSlice'; function UserProfile({ userId }) { const dispatch = useDispatch(); const user = useSelector(selectCurrentUser); const status = useSelector(selectUserStatus); const error = useSelector(selectUserError); useEffect(() => { if (userId) { // Dispatch returns a promise dispatch(fetchUserById(userId)); } return () => { dispatch(clearCurrentUser()); }; }, [userId, dispatch]); if (status === 'loading') { return ( <div className="loading"> <div className="spinner"></div> <p>Loading user profile...</p> </div> ); } if (status === 'failed') { return ( <div className="error"> <p>Error: {error}</p> <button onClick={() => dispatch(fetchUserById(userId))}> Retry </button> </div> ); } if (!user) { return <div>No user found</div>; } return ( <div className="user-profile"> <img src={user.avatar} alt={user.name} /> <h2>{user.name}</h2> <p>{user.email}</p> <p>{user.bio}</p> </div> ); }

Handling Promise Results

createAsyncThunk returns a promise, allowing you to handle results in components:

// components/EditUserForm.jsx import { useState } from 'react'; import { useDispatch } from 'react-redux'; import { updateUser } from '../store/slices/userSlice'; function EditUserForm({ user }) { const dispatch = useDispatch(); const [formData, setFormData] = useState({ name: user.name, email: user.email, bio: user.bio }); const [isSubmitting, setIsSubmitting] = useState(false); const handleSubmit = async (e) => { e.preventDefault(); setIsSubmitting(true); try { // unwrap() throws on rejected, returns payload on fulfilled const result = await dispatch( updateUser({ userId: user.id, updates: formData }) ).unwrap(); alert('User updated successfully!'); console.log('Updated user:', result); } catch (error) { alert(`Failed to update: ${error}`); } finally { setIsSubmitting(false); } }; return ( <form onSubmit={handleSubmit}> <input type="text" value={formData.name} onChange={(e) => setFormData({ ...formData, name: e.target.value })} /> <input type="email" value={formData.email} onChange={(e) => setFormData({ ...formData, email: e.target.value })} /> <textarea value={formData.bio} onChange={(e) => setFormData({ ...formData, bio: e.target.value })} /> <button type="submit" disabled={isSubmitting}> {isSubmitting ? 'Saving...' : 'Save Changes'} </button> </form> ); }
Error Handling: Always use try-catch when calling unwrap() on dispatched thunks. Without unwrap(), the promise never rejects - check action.error in .then() instead. The unwrap() method makes error handling more intuitive for component-level logic.

Advanced: Conditional Thunks

Prevent unnecessary API calls by checking state before executing:

// store/slices/postsSlice.js import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'; export const fetchPosts = createAsyncThunk( 'posts/fetch', async (_, { getState, rejectWithValue }) => { try { const response = await fetch('https://api.example.com/posts'); if (!response.ok) throw new Error('Failed to fetch'); return await response.json(); } catch (error) { return rejectWithValue(error.message); } }, { // Condition function - return false to cancel dispatch condition: (_, { getState }) => { const { posts } = getState(); const { status, lastFetch } = posts; // Don't fetch if already loading if (status === 'loading') { return false; } // Don't fetch if data is fresh (less than 5 minutes old) if (lastFetch) { const fiveMinutes = 5 * 60 * 1000; const timeSinceLastFetch = Date.now() - lastFetch; if (timeSinceLastFetch < fiveMinutes) { return false; } } return true; } } ); const postsSlice = createSlice({ name: 'posts', initialState: { items: [], status: 'idle', error: null, lastFetch: null }, reducers: {}, extraReducers: (builder) => { builder .addCase(fetchPosts.pending, (state) => { state.status = 'loading'; }) .addCase(fetchPosts.fulfilled, (state, action) => { state.status = 'succeeded'; state.items = action.payload; state.lastFetch = Date.now(); }) .addCase(fetchPosts.rejected, (state, action) => { state.status = 'failed'; state.error = action.payload; }); } }); export default postsSlice.reducer;

Optimistic Updates

Update UI immediately, then sync with server:

// store/slices/todosSlice.js import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'; export const toggleTodo = createAsyncThunk( 'todos/toggle', async (todoId, { rejectWithValue }) => { try { const response = await fetch( `https://api.example.com/todos/${todoId}/toggle`, { method: 'PATCH' } ); if (!response.ok) throw new Error('Failed to toggle'); return await response.json(); } catch (error) { return rejectWithValue({ todoId, error: error.message }); } } ); const todosSlice = createSlice({ name: 'todos', initialState: { items: [], optimisticUpdates: {} }, reducers: { toggleTodoOptimistic: (state, action) => { const todo = state.items.find(t => t.id === action.payload); if (todo) { // Store original state for rollback state.optimisticUpdates[todo.id] = todo.completed; todo.completed = !todo.completed; } } }, extraReducers: (builder) => { builder .addCase(toggleTodo.fulfilled, (state, action) => { // Confirm the optimistic update delete state.optimisticUpdates[action.payload.id]; const todo = state.items.find(t => t.id === action.payload.id); if (todo) { todo.completed = action.payload.completed; } }) .addCase(toggleTodo.rejected, (state, action) => { // Rollback optimistic update const { todoId } = action.payload; const originalState = state.optimisticUpdates[todoId]; if (originalState !== undefined) { const todo = state.items.find(t => t.id === todoId); if (todo) { todo.completed = originalState; } delete state.optimisticUpdates[todoId]; } }); } }); export const { toggleTodoOptimistic } = todosSlice.actions; export default todosSlice.reducer; // Usage in component const handleToggle = (todoId) => { dispatch(toggleTodoOptimistic(todoId)); // Immediate UI update dispatch(toggleTodo(todoId)); // Sync with server };

Practice Exercise 1: Products API with Pagination

Build a paginated product list:

  1. Create fetchProducts thunk accepting page and limit parameters
  2. Store products, currentPage, totalPages, and loading state
  3. Implement ProductList component with pagination controls
  4. Add searchProducts thunk with debouncing
  5. Handle loading states with skeleton screens

Practice Exercise 2: File Upload with Progress

Create an image upload system:

  1. Build uploadImage thunk with FormData and progress tracking
  2. Use XMLHttpRequest or fetch with ReadableStream for progress
  3. Store upload progress percentage in Redux
  4. Display progress bar during upload
  5. Handle upload cancellation with AbortController

Practice Exercise 3: Shopping Cart Sync

Build a cart that syncs with backend:

  1. Create addToCart, removeFromCart, updateQuantity thunks
  2. Implement optimistic updates for instant UI feedback
  3. Add rollback mechanism for failed API calls
  4. Store sync status (synced/syncing/error) per item
  5. Show sync indicators next to each cart item

Summary

createAsyncThunk simplifies async Redux operations by automatically handling pending, fulfilled, and rejected states. Use extraReducers to handle these lifecycle actions, leverage the ThunkAPI for advanced features, and implement patterns like conditional fetching and optimistic updates for better user experience. Always handle errors properly with try-catch and rejectWithValue.