Master asynchronous state management using Redux Thunks and advanced async patterns.
Master asynchronous state management using Redux Thunks and advanced async patterns.
Redux is synchronous by nature - actions are plain objects and reducers are pure functions. To handle asynchronous operations like API calls, we need middleware and special patterns.
1// ❌ This doesn't work - actions must be plain objects
2const fetchUser = (userId) => {
3 return fetch(`/api/users/${userId}`)
4 .then(response => response.json());
5};
6
7// ❌ This doesn't work - reducers can't handle promises
8function userReducer(state, action) {
9 switch (action.type) {
10 case 'USER_FETCH':
11 return { ...state, user: action.payload }; // payload is a Promise!
12 }
13}Thunk middleware allows action creators to return functions instead of plain objects.
1// ✅ This works with thunk middleware
2const fetchUser = (userId) => {
3 return async (dispatch, getState) => {
4 dispatch({ type: 'USER_FETCH_STARTED' });
5
6 try {
7 const response = await fetch(`/api/users/${userId}`);
8 const user = await response.json();
9 dispatch({ type: 'USER_FETCH_SUCCEEDED', payload: user });
10 } catch (error) {
11 dispatch({ type: 'USER_FETCH_FAILED', payload: error.message });
12 }
13 };
14};1// 1. Pending State
2dispatch({
3 type: 'DATA_FETCH_PENDING',
4 meta: { requestId: 'abc123' }
5});
6
7// 2. Success State
8dispatch({
9 type: 'DATA_FETCH_FULFILLED',
10 payload: data,
11 meta: { requestId: 'abc123' }
12});
13
14// 3. Error State
15dispatch({
16 type: 'DATA_FETCH_REJECTED',
17 error: true,
18 payload: error,
19 meta: { requestId: 'abc123' }
20});1const initialState = {
2 // Data
3 users: [],
4 posts: [],
5
6 // Loading states
7 loading: {
8 users: false,
9 posts: false
10 },
11
12 // Error states
13 errors: {
14 users: null,
15 posts: null
16 },
17
18 // Request metadata
19 lastFetch: {
20 users: null,
21 posts: null
22 },
23
24 // Optimistic updates
25 optimisticUpdates: []
26};1// Action types
2const FETCH_POSTS_START = 'posts/fetchStart';
3const FETCH_POSTS_SUCCESS = 'posts/fetchSuccess';
4const FETCH_POSTS_FAILURE = 'posts/fetchFailure';
5
6// Action creators
7const fetchPostsStart = () => ({ type: FETCH_POSTS_START });
8const fetchPostsSuccess = (posts) => ({
9 type: FETCH_POSTS_SUCCESS,
10 payload: posts
11});
12const fetchPostsFailure = (error) => ({
13 type: FETCH_POSTS_FAILURE,
14 payload: error
15});
16
17// Thunk action creator
18const fetchPosts = () => async (dispatch, getState) => {
19 dispatch(fetchPostsStart());
20
21 try {
22 const response = await api.get('/posts');
23 dispatch(fetchPostsSuccess(response.data));
24 } catch (error) {
25 dispatch(fetchPostsFailure(error.message));
26 }
27};
28
29// Reducer
30function postsReducer(state = initialState, action) {
31 switch (action.type) {
32 case FETCH_POSTS_START:
33 return {
34 ...state,
35 loading: { ...state.loading, posts: true },
36 errors: { ...state.errors, posts: null }
37 };
38
39 case FETCH_POSTS_SUCCESS:
40 return {
41 ...state,
42 posts: action.payload,
43 loading: { ...state.loading, posts: false },
44 lastFetch: { ...state.lastFetch, posts: Date.now() }
45 };
46
47 case FETCH_POSTS_FAILURE:
48 return {
49 ...state,
50 loading: { ...state.loading, posts: false },
51 errors: { ...state.errors, posts: action.payload }
52 };
53
54 default:
55 return state;
56 }
57}RTK's createAsyncThunk simplifies async operations by automatically generating action types and handling the async lifecycle.
1import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
2
3// Create async thunk
4export const fetchUserById = createAsyncThunk(
5 'users/fetchById', // Action type prefix
6 async (userId, thunkAPI) => {
7 const response = await fetch(`/api/users/${userId}`);
8
9 if (!response.ok) {
10 throw new Error('Failed to fetch user');
11 }
12
13 return response.json();
14 }
15);
16
17// Automatically generates:
18// - users/fetchById/pending
19// - users/fetchById/fulfilled
20// - users/fetchById/rejected1const usersSlice = createSlice({
2 name: 'users',
3 initialState: {
4 entities: {},
5 loading: false,
6 error: null
7 },
8 reducers: {
9 // Synchronous reducers
10 userUpdated: (state, action) => {
11 const { id, changes } = action.payload;
12 if (state.entities[id]) {
13 Object.assign(state.entities[id], changes);
14 }
15 }
16 },
17 extraReducers: (builder) => {
18 builder
19 .addCase(fetchUserById.pending, (state) => {
20 state.loading = true;
21 state.error = null;
22 })
23 .addCase(fetchUserById.fulfilled, (state, action) => {
24 state.loading = false;
25 state.entities[action.payload.id] = action.payload;
26 })
27 .addCase(fetchUserById.rejected, (state, action) => {
28 state.loading = false;
29 state.error = action.error.message;
30 });
31 }
32});1const fetchUserPosts = createAsyncThunk(
2 'posts/fetchByUser',
3 async (userId, { dispatch, getState, rejectWithValue, signal }) => {
4 // Access current state
5 const state = getState();
6 const existingPosts = state.posts.byUserId[userId];
7
8 // Skip if data is fresh (less than 5 minutes old)
9 if (existingPosts && existingPosts.lastFetch > Date.now() - 5 * 60 * 1000) {
10 return existingPosts.data;
11 }
12
13 // Dispatch other actions
14 dispatch(trackApiCall({ endpoint: 'posts', userId }));
15
16 try {
17 const response = await fetch(`/api/users/${userId}/posts`, {
18 signal // For cancellation
19 });
20
21 if (!response.ok) {
22 // Custom rejection with additional data
23 return rejectWithValue({
24 message: 'Failed to fetch posts',
25 status: response.status,
26 userId
27 });
28 }
29
30 const posts = await response.json();
31
32 // Dispatch success analytics
33 dispatch(trackApiSuccess({ endpoint: 'posts', count: posts.length }));
34
35 return { posts, userId, timestamp: Date.now() };
36
37 } catch (error) {
38 if (error.name === 'AbortError') {
39 return rejectWithValue({ message: 'Request cancelled', cancelled: true });
40 }
41
42 throw error;
43 }
44 }
45);1const fetchUserIfNeeded = createAsyncThunk(
2 'users/fetchIfNeeded',
3 async (userId, { getState }) => {
4 const state = getState();
5 const user = state.users.entities[userId];
6
7 // Only fetch if user doesn't exist or is stale
8 if (user && Date.now() - user.lastFetch < 60000) {
9 // Return existing data to skip the request
10 return user;
11 }
12
13 const response = await fetch(`/api/users/${userId}`);
14 return response.json();
15 },
16 {
17 condition: (userId, { getState }) => {
18 const state = getState();
19 const user = state.users.entities[userId];
20
21 // Don't execute if already loading
22 if (state.users.loading[userId]) {
23 return false;
24 }
25
26 // Don't execute if data is fresh
27 if (user && Date.now() - user.lastFetch < 60000) {
28 return false;
29 }
30
31 return true;
32 }
33 }
34);1const fetchWithRetry = createAsyncThunk(
2 'data/fetchWithRetry',
3 async (url, { rejectWithValue }) => {
4 const maxRetries = 3;
5 const retryDelay = 1000;
6
7 for (let attempt = 1; attempt <= maxRetries; attempt++) {
8 try {
9 const response = await fetch(url);
10
11 if (!response.ok) {
12 throw new Error(`HTTP ${response.status}`);
13 }
14
15 return await response.json();
16
17 } catch (error) {
18 if (attempt === maxRetries) {
19 return rejectWithValue({
20 message: `Failed after ${maxRetries} attempts`,
21 lastError: error.message,
22 attempts: attempt
23 });
24 }
25
26 // Wait before retry
27 await new Promise(resolve => setTimeout(resolve, retryDelay * attempt));
28 }
29 }
30 }
31);1const createPostWithImage = createAsyncThunk(
2 'posts/createWithImage',
3 async ({ postData, imageFile }, { dispatch, rejectWithValue }) => {
4 try {
5 // Step 1: Upload image if provided
6 let imageUrl = null;
7 if (imageFile) {
8 dispatch(updateProgress({ step: 'uploading-image', progress: 0 }));
9
10 const formData = new FormData();
11 formData.append('image', imageFile);
12
13 const uploadResponse = await fetch('/api/upload', {
14 method: 'POST',
15 body: formData
16 });
17
18 if (!uploadResponse.ok) {
19 throw new Error('Image upload failed');
20 }
21
22 const uploadResult = await uploadResponse.json();
23 imageUrl = uploadResult.url;
24
25 dispatch(updateProgress({ step: 'uploading-image', progress: 100 }));
26 }
27
28 // Step 2: Create post with image URL
29 dispatch(updateProgress({ step: 'creating-post', progress: 0 }));
30
31 const postResponse = await fetch('/api/posts', {
32 method: 'POST',
33 headers: { 'Content-Type': 'application/json' },
34 body: JSON.stringify({
35 ...postData,
36 imageUrl
37 })
38 });
39
40 if (!postResponse.ok) {
41 // If post creation fails, clean up uploaded image
42 if (imageUrl) {
43 fetch(`/api/images/${imageUrl.split('/').pop()}`, { method: 'DELETE' });
44 }
45 throw new Error('Post creation failed');
46 }
47
48 const post = await postResponse.json();
49
50 dispatch(updateProgress({ step: 'creating-post', progress: 100 }));
51
52 // Step 3: Update user's post count
53 dispatch(incrementUserPostCount(post.authorId));
54
55 return post;
56
57 } catch (error) {
58 return rejectWithValue({
59 message: error.message,
60 step: 'unknown'
61 });
62 }
63 }
64);1const apiSlice = createSlice({
2 name: 'api',
3 initialState: {
4 data: {},
5 loading: {},
6 errors: {},
7 retryCount: {}
8 },
9 extraReducers: (builder) => {
10 builder
11 .addCase(fetchData.rejected, (state, action) => {
12 const { meta, payload } = action;
13 const key = meta.arg; // Original argument
14
15 state.loading[key] = false;
16
17 if (payload?.cancelled) {
18 // Handle cancellation
19 state.errors[key] = { type: 'cancelled', message: 'Request was cancelled' };
20 } else if (payload?.status === 401) {
21 // Handle authentication errors
22 state.errors[key] = { type: 'auth', message: 'Authentication required' };
23 } else if (payload?.status >= 500) {
24 // Handle server errors with retry
25 state.errors[key] = { type: 'server', message: payload.message, retryable: true };
26 state.retryCount[key] = (state.retryCount[key] || 0) + 1;
27 } else {
28 // Handle client errors
29 state.errors[key] = { type: 'client', message: payload?.message || action.error.message };
30 }
31 });
32 }
33});1// Middleware for global error handling
2const errorHandlingMiddleware = (store) => (next) => (action) => {
3 if (action.type.endsWith('/rejected')) {
4 // Log error to external service
5 errorLogger.log({
6 action: action.type,
7 error: action.error,
8 payload: action.payload,
9 timestamp: Date.now(),
10 userId: store.getState().auth.user?.id
11 });
12
13 // Show toast notification for user-facing errors
14 if (action.payload?.userFacing) {
15 store.dispatch(showErrorToast(action.payload.message));
16 }
17 }
18
19 return next(action);
20};Beyond basic API calls, real applications require sophisticated async patterns for optimal user experience and performance.
1const searchUsers = createAsyncThunk(
2 'users/search',
3 async (query, { signal }) => {
4 // AbortController signal is automatically provided
5 const response = await fetch(`/api/users/search?q=${query}`, {
6 signal
7 });
8
9 return response.json();
10 }
11);
12
13// Usage with automatic cancellation
14const SearchComponent = () => {
15 const dispatch = useAppDispatch();
16 const [query, setQuery] = useState('');
17
18 useEffect(() => {
19 if (query.trim()) {
20 // Previous search is automatically cancelled when new one starts
21 const promise = dispatch(searchUsers(query));
22
23 return () => {
24 // Manual cancellation if component unmounts
25 promise.abort();
26 };
27 }
28 }, [query, dispatch]);
29};1class ApiService {
2 private controllers = new Map();
3
4 async fetchWithCancellation(key, url, options = {}) {
5 // Cancel previous request with same key
6 const existingController = this.controllers.get(key);
7 if (existingController) {
8 existingController.abort();
9 }
10
11 // Create new controller
12 const controller = new AbortController();
13 this.controllers.set(key, controller);
14
15 try {
16 const response = await fetch(url, {
17 ...options,
18 signal: controller.signal
19 });
20
21 return await response.json();
22 } finally {
23 // Clean up
24 this.controllers.delete(key);
25 }
26 }
27}
28
29const fetchUserData = createAsyncThunk(
30 'users/fetchData',
31 async ({ userId }, { rejectWithValue }) => {
32 try {
33 return await apiService.fetchWithCancellation(
34 `user-${userId}`,
35 `/api/users/${userId}`
36 );
37 } catch (error) {
38 if (error.name === 'AbortError') {
39 return rejectWithValue({ cancelled: true });
40 }
41 throw error;
42 }
43 }
44);1const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
2
3const fetchWithCache = createAsyncThunk(
4 'data/fetchWithCache',
5 async (key, { getState, rejectWithValue }) => {
6 const state = getState();
7 const cached = state.cache[key];
8
9 // Return cached data if fresh
10 if (cached && Date.now() - cached.timestamp < CACHE_DURATION) {
11 return cached.data;
12 }
13
14 try {
15 const response = await fetch(`/api/data/${key}`);
16 const data = await response.json();
17
18 return {
19 data,
20 timestamp: Date.now(),
21 key
22 };
23 } catch (error) {
24 return rejectWithValue({ message: error.message, key });
25 }
26 }
27);
28
29// Slice with caching
30const cacheSlice = createSlice({
31 name: 'cache',
32 initialState: {},
33 extraReducers: (builder) => {
34 builder.addCase(fetchWithCache.fulfilled, (state, action) => {
35 const { key, data, timestamp } = action.payload;
36 state[key] = { data, timestamp };
37 });
38 }
39});1const fetchDataWithBackgroundRefresh = createAsyncThunk(
2 'data/fetchWithBackgroundRefresh',
3 async ({ key, forceRefresh = false }, { getState, dispatch }) => {
4 const state = getState();
5 const cached = state.cache[key];
6
7 // Return cached data immediately if available and not forced
8 if (cached && !forceRefresh) {
9 // Trigger background refresh if data is getting stale
10 const age = Date.now() - cached.timestamp;
11 const STALE_THRESHOLD = 2 * 60 * 1000; // 2 minutes
12
13 if (age > STALE_THRESHOLD) {
14 // Background refresh - don't await
15 dispatch(fetchDataWithBackgroundRefresh({ key, forceRefresh: true }));
16 }
17
18 return cached.data;
19 }
20
21 // Fetch fresh data
22 const response = await fetch(`/api/data/${key}`);
23 const data = await response.json();
24
25 return {
26 data,
27 timestamp: Date.now(),
28 key,
29 isBackgroundUpdate: !forceRefresh && !!cached
30 };
31 }
32);1const fetchUserDashboard = createAsyncThunk(
2 'dashboard/fetchUserData',
3 async (userId, { dispatch, rejectWithValue }) => {
4 try {
5 // Execute multiple async operations in parallel
6 const [userProfile, userPosts, userNotifications, userSettings] = await Promise.all([
7 dispatch(fetchUserProfile(userId)).unwrap(),
8 dispatch(fetchUserPosts(userId)).unwrap(),
9 dispatch(fetchUserNotifications(userId)).unwrap(),
10 dispatch(fetchUserSettings(userId)).unwrap()
11 ]);
12
13 return {
14 profile: userProfile,
15 posts: userPosts,
16 notifications: userNotifications,
17 settings: userSettings
18 };
19
20 } catch (error) {
21 return rejectWithValue({
22 message: 'Failed to load dashboard data',
23 userId,
24 error: error.message
25 });
26 }
27 }
28);1const processOrderWorkflow = createAsyncThunk(
2 'orders/processWorkflow',
3 async (orderData, { dispatch, rejectWithValue }) => {
4 try {
5 // Step 1: Validate order
6 const validation = await dispatch(validateOrder(orderData)).unwrap();
7
8 if (!validation.valid) {
9 return rejectWithValue({
10 step: 'validation',
11 errors: validation.errors
12 });
13 }
14
15 // Step 2: Process payment
16 const payment = await dispatch(processPayment({
17 orderId: validation.orderId,
18 amount: validation.totalAmount,
19 paymentMethod: orderData.paymentMethod
20 })).unwrap();
21
22 // Step 3: Update inventory
23 await dispatch(updateInventory(validation.items)).unwrap();
24
25 // Step 4: Send confirmation
26 await dispatch(sendOrderConfirmation({
27 orderId: payment.orderId,
28 customerEmail: orderData.customerEmail
29 })).unwrap();
30
31 return {
32 orderId: payment.orderId,
33 status: 'completed',
34 steps: ['validation', 'payment', 'inventory', 'confirmation']
35 };
36
37 } catch (error) {
38 // Rollback on failure
39 if (error.orderId) {
40 dispatch(rollbackOrder(error.orderId));
41 }
42
43 return rejectWithValue({
44 message: 'Order processing failed',
45 error: error.message,
46 step: error.step || 'unknown'
47 });
48 }
49 }
50);1const connectWebSocket = createAsyncThunk(
2 'realtime/connect',
3 async (_, { dispatch, getState, signal }) => {
4 const ws = new WebSocket('wss://api.example.com/ws');
5
6 ws.onopen = () => {
7 dispatch(websocketConnected());
8 };
9
10 ws.onmessage = (event) => {
11 const data = JSON.parse(event.data);
12
13 switch (data.type) {
14 case 'USER_UPDATE':
15 dispatch(userUpdated(data.payload));
16 break;
17 case 'POST_CREATED':
18 dispatch(postAdded(data.payload));
19 break;
20 case 'NOTIFICATION':
21 dispatch(notificationReceived(data.payload));
22 break;
23 }
24 };
25
26 ws.onerror = (error) => {
27 dispatch(websocketError(error));
28 };
29
30 ws.onclose = () => {
31 dispatch(websocketDisconnected());
32
33 // Auto-reconnect after delay
34 setTimeout(() => {
35 if (!signal.aborted) {
36 dispatch(connectWebSocket());
37 }
38 }, 5000);
39 };
40
41 // Handle cleanup
42 signal.addEventListener('abort', () => {
43 ws.close();
44 });
45
46 return { connected: true, timestamp: Date.now() };
47 }
48);1const updatePostOptimistic = createAsyncThunk(
2 'posts/updateOptimistic',
3 async ({ postId, updates }, { dispatch, getState, rejectWithValue }) => {
4 const state = getState();
5 const originalPost = state.posts.entities[postId];
6
7 // Apply optimistic update immediately
8 dispatch(postUpdatedOptimistically({ postId, updates }));
9
10 try {
11 const response = await fetch(`/api/posts/${postId}`, {
12 method: 'PATCH',
13 headers: { 'Content-Type': 'application/json' },
14 body: JSON.stringify(updates)
15 });
16
17 if (!response.ok) {
18 throw new Error(`Update failed: ${response.status}`);
19 }
20
21 const updatedPost = await response.json();
22
23 // Confirm optimistic update with server data
24 dispatch(postUpdateConfirmed({ postId, post: updatedPost }));
25
26 return updatedPost;
27
28 } catch (error) {
29 // Revert optimistic update
30 dispatch(postUpdateReverted({ postId, originalPost }));
31
32 return rejectWithValue({
33 message: error.message,
34 postId,
35 originalPost
36 });
37 }
38 }
39);1let searchTimeout;
2
3const debouncedSearch = createAsyncThunk(
4 'search/debounced',
5 async (query, { rejectWithValue, signal }) => {
6 // Clear previous timeout
7 if (searchTimeout) {
8 clearTimeout(searchTimeout);
9 }
10
11 // Debounce the actual API call
12 return new Promise((resolve, reject) => {
13 searchTimeout = setTimeout(async () => {
14 try {
15 const response = await fetch(`/api/search?q=${query}`, { signal });
16 const results = await response.json();
17 resolve(results);
18 } catch (error) {
19 if (error.name === 'AbortError') {
20 reject(rejectWithValue({ cancelled: true }));
21 } else {
22 reject(error);
23 }
24 }
25 }, 300);
26
27 // Handle cancellation
28 signal.addEventListener('abort', () => {
29 clearTimeout(searchTimeout);
30 reject(rejectWithValue({ cancelled: true }));
31 });
32 });
33 }
34);1const pendingRequests = new Map();
2
3const fetchWithDeduplication = createAsyncThunk(
4 'data/fetchDeduped',
5 async (key, { rejectWithValue }) => {
6 // Check for existing request
7 const existingRequest = pendingRequests.get(key);
8 if (existingRequest) {
9 return await existingRequest;
10 }
11
12 // Create new request
13 const requestPromise = fetch(`/api/data/${key}`)
14 .then(response => response.json())
15 .finally(() => {
16 // Clean up after completion
17 pendingRequests.delete(key);
18 });
19
20 // Store pending request
21 pendingRequests.set(key, requestPromise);
22
23 try {
24 return await requestPromise;
25 } catch (error) {
26 return rejectWithValue({ message: error.message, key });
27 }
28 }
29);These advanced patterns enable building robust, performant applications that handle complex async scenarios gracefully while providing excellent user experiences.
Testing async actions requires special consideration for timing, mocking, and state verification. Here are comprehensive strategies for testing createAsyncThunk actions.
1import { configureStore } from '@reduxjs/toolkit';
2import { fetchUserById } from './userSlice';
3
4describe('fetchUserById', () => {
5 let store;
6
7 beforeEach(() => {
8 store = configureStore({
9 reducer: {
10 users: userReducer
11 }
12 });
13
14 // Mock fetch
15 global.fetch = jest.fn();
16 });
17
18 afterEach(() => {
19 jest.resetAllMocks();
20 });
21
22 test('should handle successful API call', async () => {
23 const userData = { id: 1, name: 'John Doe' };
24
25 fetch.mockResolvedValueOnce({
26 ok: true,
27 json: async () => userData
28 });
29
30 const result = await store.dispatch(fetchUserById(1));
31
32 expect(result.type).toBe('users/fetchById/fulfilled');
33 expect(result.payload).toEqual(userData);
34 expect(fetch).toHaveBeenCalledWith('/api/users/1');
35 });
36
37 test('should handle API failure', async () => {
38 fetch.mockRejectedValueOnce(new Error('Network error'));
39
40 const result = await store.dispatch(fetchUserById(1));
41
42 expect(result.type).toBe('users/fetchById/rejected');
43 expect(result.error.message).toBe('Network error');
44 });
45
46 test('should update loading state correctly', async () => {
47 fetch.mockImplementationOnce(() =>
48 new Promise(resolve => setTimeout(() => resolve({
49 ok: true,
50 json: () => Promise.resolve({ id: 1, name: 'John' })
51 }), 100))
52 );
53
54 const promise = store.dispatch(fetchUserById(1));
55
56 // Check loading state
57 expect(store.getState().users.loading).toBe(true);
58
59 await promise;
60
61 // Check final state
62 expect(store.getState().users.loading).toBe(false);
63 expect(store.getState().users.entities[1]).toEqual({
64 id: 1,
65 name: 'John'
66 });
67 });
68});1import configureStore from 'redux-mock-store';
2import thunk from 'redux-thunk';
3
4const mockStore = configureStore([thunk]);
5
6describe('async actions with mock store', () => {
7 test('should dispatch correct actions on successful fetch', async () => {
8 const store = mockStore({
9 users: { entities: {}, loading: false }
10 });
11
12 const userData = { id: 1, name: 'John Doe' };
13 fetch.mockResolvedValueOnce({
14 ok: true,
15 json: async () => userData
16 });
17
18 await store.dispatch(fetchUserById(1));
19
20 const actions = store.getActions();
21
22 expect(actions[0].type).toBe('users/fetchById/pending');
23 expect(actions[1].type).toBe('users/fetchById/fulfilled');
24 expect(actions[1].payload).toEqual(userData);
25 });
26});1import { act, renderHook } from '@testing-library/react';
2import { Provider } from 'react-redux';
3import { store } from '../store';
4
5const wrapper = ({ children }) => (
6 <Provider store={store}>{children}</Provider>
7);
8
9describe('User dashboard workflow', () => {
10 test('should load all user data correctly', async () => {
11 // Mock multiple API endpoints
12 fetch
13 .mockResolvedValueOnce({
14 ok: true,
15 json: async () => ({ id: 1, name: 'John Doe' })
16 })
17 .mockResolvedValueOnce({
18 ok: true,
19 json: async () => ([{ id: 1, title: 'Post 1' }])
20 })
21 .mockResolvedValueOnce({
22 ok: true,
23 json: async () => ({ theme: 'dark', notifications: true })
24 });
25
26 const { result } = renderHook(() => useAppSelector(state => state), {
27 wrapper
28 });
29
30 await act(async () => {
31 await store.dispatch(fetchUserDashboard(1));
32 });
33
34 expect(result.current.users.entities[1]).toBeDefined();
35 expect(result.current.posts.byUserId[1]).toHaveLength(1);
36 expect(result.current.settings.theme).toBe('dark');
37 });
38});1describe('Error recovery patterns', () => {
2 test('should retry on network failure', async () => {
3 const store = configureStore({
4 reducer: { api: apiReducer }
5 });
6
7 // First two calls fail, third succeeds
8 fetch
9 .mockRejectedValueOnce(new Error('Network error'))
10 .mockRejectedValueOnce(new Error('Network error'))
11 .mockResolvedValueOnce({
12 ok: true,
13 json: async () => ({ data: 'success' })
14 });
15
16 const result = await store.dispatch(fetchWithRetry('/api/data'));
17
18 expect(result.type).toBe('data/fetchWithRetry/fulfilled');
19 expect(fetch).toHaveBeenCalledTimes(3);
20 });
21
22 test('should handle maximum retry attempts', async () => {
23 const store = configureStore({
24 reducer: { api: apiReducer }
25 });
26
27 fetch.mockRejectedValue(new Error('Persistent error'));
28
29 const result = await store.dispatch(fetchWithRetry('/api/data'));
30
31 expect(result.type).toBe('data/fetchWithRetry/rejected');
32 expect(result.payload.attempts).toBe(3);
33 });
34});1// Create a mock API service
2class MockApiService {
3 constructor() {
4 this.responses = new Map();
5 this.delays = new Map();
6 this.failures = new Map();
7 }
8
9 mockResponse(endpoint, response, delay = 0) {
10 this.responses.set(endpoint, response);
11 this.delays.set(endpoint, delay);
12 }
13
14 mockFailure(endpoint, error) {
15 this.failures.set(endpoint, error);
16 }
17
18 async fetch(url, options = {}) {
19 const endpoint = url.replace('/api/', '');
20
21 // Simulate delay
22 const delay = this.delays.get(endpoint) || 0;
23 if (delay > 0) {
24 await new Promise(resolve => setTimeout(resolve, delay));
25 }
26
27 // Check for configured failure
28 if (this.failures.has(endpoint)) {
29 throw this.failures.get(endpoint);
30 }
31
32 // Return mocked response
33 const response = this.responses.get(endpoint);
34 if (response) {
35 return {
36 ok: true,
37 json: async () => response,
38 status: 200
39 };
40 }
41
42 throw new Error(`No mock configured for ${endpoint}`);
43 }
44}
45
46// Use in tests
47describe('API integration', () => {
48 let mockApi;
49
50 beforeEach(() => {
51 mockApi = new MockApiService();
52 global.fetch = mockApi.fetch.bind(mockApi);
53 });
54
55 test('should handle slow API responses', async () => {
56 mockApi.mockResponse('users/1', { id: 1, name: 'John' }, 1000);
57
58 const start = Date.now();
59 await store.dispatch(fetchUserById(1));
60 const duration = Date.now() - start;
61
62 expect(duration).toBeGreaterThan(900);
63 });
64});1class MockWebSocket {
2 constructor(url) {
3 this.url = url;
4 this.readyState = WebSocket.CONNECTING;
5 this.onopen = null;
6 this.onmessage = null;
7 this.onerror = null;
8 this.onclose = null;
9
10 // Simulate connection
11 setTimeout(() => {
12 this.readyState = WebSocket.OPEN;
13 if (this.onopen) {
14 this.onopen();
15 }
16 }, 10);
17 }
18
19 send(data) {
20 if (this.readyState !== WebSocket.OPEN) {
21 throw new Error('WebSocket is not open');
22 }
23 // Simulate server echo for testing
24 setTimeout(() => {
25 if (this.onmessage) {
26 this.onmessage({ data: `Echo: ${data}` });
27 }
28 }, 5);
29 }
30
31 close() {
32 this.readyState = WebSocket.CLOSED;
33 if (this.onclose) {
34 this.onclose();
35 }
36 }
37
38 // Test helpers
39 simulateMessage(data) {
40 if (this.onmessage) {
41 this.onmessage({ data: JSON.stringify(data) });
42 }
43 }
44
45 simulateError(error) {
46 if (this.onerror) {
47 this.onerror(error);
48 }
49 }
50}
51
52describe('WebSocket integration', () => {
53 beforeEach(() => {
54 global.WebSocket = MockWebSocket;
55 });
56
57 test('should handle websocket messages', async () => {
58 const promise = store.dispatch(connectWebSocket());
59
60 // Wait for connection
61 await new Promise(resolve => setTimeout(resolve, 20));
62
63 // Simulate incoming message
64 const ws = MockWebSocket.instances[0];
65 ws.simulateMessage({
66 type: 'USER_UPDATE',
67 payload: { id: 1, status: 'online' }
68 });
69
70 expect(store.getState().users.entities[1].status).toBe('online');
71 });
72});1describe('Performance tests', () => {
2 test('should handle concurrent requests efficiently', async () => {
3 const userIds = Array.from({ length: 100 }, (_, i) => i + 1);
4
5 fetch.mockImplementation(() =>
6 Promise.resolve({
7 ok: true,
8 json: () => Promise.resolve({ id: Math.random() })
9 })
10 );
11
12 const start = performance.now();
13
14 await Promise.all(
15 userIds.map(id => store.dispatch(fetchUserById(id)))
16 );
17
18 const duration = performance.now() - start;
19
20 expect(duration).toBeLessThan(1000); // Should complete within 1 second
21 expect(fetch).toHaveBeenCalledTimes(100);
22 });
23
24 test('should deduplicate identical requests', async () => {
25 fetch.mockResolvedValue({
26 ok: true,
27 json: async () => ({ id: 1, name: 'John' })
28 });
29
30 // Dispatch same request multiple times
31 await Promise.all([
32 store.dispatch(fetchWithDeduplication('user-1')),
33 store.dispatch(fetchWithDeduplication('user-1')),
34 store.dispatch(fetchWithDeduplication('user-1'))
35 ]);
36
37 expect(fetch).toHaveBeenCalledTimes(1);
38 });
39});1describe('Memory leak prevention', () => {
2 test('should clean up cancelled requests', async () => {
3 const abortSpy = jest.spyOn(AbortController.prototype, 'abort');
4
5 // Start request
6 const promise1 = store.dispatch(searchUsers('john'));
7
8 // Cancel with new request
9 const promise2 = store.dispatch(searchUsers('jane'));
10
11 await promise2;
12
13 expect(abortSpy).toHaveBeenCalled();
14 });
15
16 test('should not leak WebSocket connections', async () => {
17 const closeSpy = jest.spyOn(MockWebSocket.prototype, 'close');
18
19 const promise = store.dispatch(connectWebSocket());
20
21 // Simulate component unmount
22 promise.abort();
23
24 expect(closeSpy).toHaveBeenCalled();
25 });
26});Comprehensive testing of async actions ensures your application handles all edge cases gracefully and performs well under various conditions.
Your progress is saved in this browser, on this device only.