createSlice and createAsyncThunk Deep Dive

Understanding createSlice

createSlice is a function that accepts an object of reducer functions, a slice name, and an initial state value, and automatically generates action creators and action types that correspond to the reducers and state.

🍰 The Layer Cake Analogy

Think of createSlice as making a layer cake:

  • Slice Name: The cake's name label
  • Initial State: The cake base
  • Reducers: Different layers of the cake
  • Actions: The frosting that connects everything
  • Extra Reducers: Decorations from other cakes

Just as a layer cake combines multiple components into one dessert, createSlice combines actions, reducers, and selectors into one feature slice.

createSlice API in Detail

graph TD A[createSlice] --> B[name] A --> C[initialState] A --> D[reducers] A --> E[extraReducers] D --> F[Regular Reducers] D --> G[Prepared Reducers] E --> H[Builder Callback] E --> I[Object Notation] A --> J[Generated Actions] A --> K[Reducer Function] style A fill:#f96 style J fill:#9f9 style K fill:#9f9

Basic createSlice Structure


import { createSlice } from '@reduxjs/toolkit';

const sliceName = createSlice({
  name: 'featureName',        // Used in action types
  initialState: {},           // Initial state value
  reducers: {                 // Reducer functions
    actionName: (state, action) => {
      // Can "mutate" state directly thanks to Immer
    }
  },
  extraReducers: (builder) => {  // Handle external actions
    builder.addCase(externalAction, (state, action) => {
      // Handle actions from other slices or createAsyncThunk
    });
  }
});

// What createSlice generates:
// 1. Action creators: sliceName.actions.actionName
// 2. Reducer function: sliceName.reducer
// 3. Action types: 'featureName/actionName'
            

Advanced Reducer Patterns


const todosSlice = createSlice({
  name: 'todos',
  initialState: {
    items: [],
    filter: 'all',
    loading: false
  },
  reducers: {
    // Simple reducer
    setFilter: (state, action) => {
      state.filter = action.payload;
    },
    
    // Reducer with prepare callback
    addTodo: {
      reducer: (state, action) => {
        state.items.push(action.payload);
      },
      prepare: (text) => {
        return {
          payload: {
            id: nanoid(),
            text,
            completed: false,
            createdAt: new Date().toISOString()
          }
        };
      }
    },
    
    // Reducer that returns a new state
    resetTodos: () => {
      // Return a new state instead of mutating
      return {
        items: [],
        filter: 'all',
        loading: false
      };
    },
    
    // Reducer with complex logic
    toggleTodo: (state, action) => {
      const todo = state.items.find(item => item.id === action.payload);
      if (todo) {
        todo.completed = !todo.completed;
        // Update completion timestamp
        todo.completedAt = todo.completed ? new Date().toISOString() : null;
      }
    },
    
    // Reducer with validation
    updateTodo: {
      reducer: (state, action) => {
        const { id, updates } = action.payload;
        const todo = state.items.find(item => item.id === id);
        if (todo) {
          Object.assign(todo, updates);
        }
      },
      prepare: (id, updates) => {
        // Validate updates
        if (!id) throw new Error('ID is required');
        if (!updates || typeof updates !== 'object') {
          throw new Error('Updates must be an object');
        }
        return { payload: { id, updates } };
      }
    }
  }
});

// Using generated actions
dispatch(todosSlice.actions.setFilter('active'));
dispatch(todosSlice.actions.addTodo('Learn Redux Toolkit'));
dispatch(todosSlice.actions.toggleTodo(todoId));
            

Working with extraReducers


const postsSlice = createSlice({
  name: 'posts',
  initialState: {
    items: [],
    status: 'idle',
    error: null
  },
  reducers: {
    postAdded: (state, action) => {
      state.items.push(action.payload);
    }
  },
  extraReducers: (builder) => {
    builder
      // Handle a specific action type
      .addCase(fetchPosts.pending, (state) => {
        state.status = 'loading';
      })
      .addCase(fetchPosts.fulfilled, (state, action) => {
        state.status = 'succeeded';
        state.items = action.payload;
      })
      .addCase(fetchPosts.rejected, (state, action) => {
        state.status = 'failed';
        state.error = action.error.message;
      })
      // Handle multiple actions with the same reducer
      .addMatcher(
        (action) => action.type.endsWith('/pending'),
        (state) => {
          state.status = 'loading';
        }
      )
      // Handle all other actions
      .addDefaultCase((state, action) => {
        // Default logic for unhandled actions
      });
  }
});

// Alternative: Object notation for extraReducers
const alternativeSlice = createSlice({
  name: 'alternative',
  initialState,
  reducers: {},
  extraReducers: {
    [fetchPosts.pending]: (state) => {
      state.status = 'loading';
    },
    [fetchPosts.fulfilled]: (state, action) => {
      state.status = 'succeeded';
      state.items = action.payload;
    },
    [fetchPosts.rejected]: (state, action) => {
      state.status = 'failed';
      state.error = action.error.message;
    }
  }
});
            

Understanding createAsyncThunk

sequenceDiagram participant C as Component participant A as AsyncThunk participant R as Reducer participant API as API/Server C->>A: dispatch(asyncThunk()) A->>R: dispatch(pending) R->>C: Update UI (loading) A->>API: Make API call API-->>A: Return response alt Success A->>R: dispatch(fulfilled) R->>C: Update UI (success) else Error A->>R: dispatch(rejected) R->>C: Update UI (error) end

createAsyncThunk Basic Usage


import { createAsyncThunk } from '@reduxjs/toolkit';

// Basic async thunk
export const fetchUserById = createAsyncThunk(
  'users/fetchById',  // Action type prefix
  async (userId, thunkAPI) => {
    const response = await fetch(`/api/users/${userId}`);
    return response.json();
  }
);

// What createAsyncThunk generates:
// 1. fetchUserById.pending: 'users/fetchById/pending'
// 2. fetchUserById.fulfilled: 'users/fetchById/fulfilled'
// 3. fetchUserById.rejected: 'users/fetchById/rejected'

// Using in a component
const UserProfile = ({ userId }) => {
  const dispatch = useDispatch();
  const { user, status, error } = useSelector(state => state.users);
  
  useEffect(() => {
    dispatch(fetchUserById(userId));
  }, [dispatch, userId]);
  
  if (status === 'loading') return 
Loading...
; if (status === 'failed') return
Error: {error}
; if (status === 'succeeded') return
Welcome, {user.name}!
; return null; };

Advanced createAsyncThunk Patterns


// Async thunk with thunkAPI parameter
export const loginUser = createAsyncThunk(
  'auth/login',
  async (credentials, thunkAPI) => {
    try {
      const response = await fetch('/api/auth/login', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(credentials)
      });
      
      if (!response.ok) {
        const error = await response.json();
        // Use rejectWithValue for custom error payload
        return thunkAPI.rejectWithValue(error.message);
      }
      
      const data = await response.json();
      
      // Access other parts of state
      const state = thunkAPI.getState();
      if (state.settings.rememberMe) {
        localStorage.setItem('token', data.token);
      }
      
      // Dispatch additional actions
      thunkAPI.dispatch(fetchUserProfile());
      
      return data;
    } catch (err) {
      // Network or other errors
      return thunkAPI.rejectWithValue('Network error occurred');
    }
  }
);

// Async thunk with condition
export const fetchNotifications = createAsyncThunk(
  'notifications/fetch',
  async (_, { getState }) => {
    const response = await fetch('/api/notifications');
    return response.json();
  },
  {
    condition: (_, { getState }) => {
      const { notifications } = getState();
      // Don't fetch if already loading
      if (notifications.status === 'loading') {
        return false;
      }
      // Don't fetch if data is fresh (less than 1 minute old)
      const lastFetched = notifications.lastFetched;
      if (lastFetched && Date.now() - lastFetched < 60000) {
        return false;
      }
      return true;
    },
    // Optional: dispatch action even if condition is false
    dispatchConditionRejection: true
  }
);

// Async thunk with progress tracking
export const uploadFile = createAsyncThunk(
  'files/upload',
  async (file, { dispatch }) => {
    const formData = new FormData();
    formData.append('file', file);
    
    return new Promise((resolve, reject) => {
      const xhr = new XMLHttpRequest();
      
      xhr.upload.onprogress = (event) => {
        if (event.lengthComputable) {
          const progress = Math.round((event.loaded / event.total) * 100);
          dispatch(updateUploadProgress(progress));
        }
      };
      
      xhr.onload = () => {
        if (xhr.status === 200) {
          resolve(JSON.parse(xhr.responseText));
        } else {
          reject(new Error('Upload failed'));
        }
      };
      
      xhr.onerror = () => reject(new Error('Network error'));
      
      xhr.open('POST', '/api/files/upload');
      xhr.send(formData);
    });
  }
);

// Async thunk with cancellation
export const searchProducts = createAsyncThunk(
  'products/search',
  async (query, { signal }) => {
    const response = await fetch(`/api/products/search?q=${query}`, {
      signal // Pass AbortController signal
    });
    return response.json();
  }
);

// Usage with cancellation
const [searchRequest, setSearchRequest] = useState();

const handleSearch = (query) => {
  // Cancel previous search
  if (searchRequest) {
    searchRequest.abort();
  }
  
  // Start new search
  const request = dispatch(searchProducts(query));
  setSearchRequest(request);
};
            

Integrating createSlice with createAsyncThunk


// Complete feature example - Posts with async operations
import { createSlice, createAsyncThunk, createSelector } from '@reduxjs/toolkit';
import { client } from '../../api/client';

// Async thunks
export const fetchPosts = createAsyncThunk('posts/fetchPosts', async () => {
  const response = await client.get('/api/posts');
  return response.data;
});

export const addNewPost = createAsyncThunk(
  'posts/addNewPost',
  async (initialPost, { getState, rejectWithValue }) => {
    try {
      // Add user info from state
      const { auth: { user } } = getState();
      const post = {
        ...initialPost,
        author: user.id,
        date: new Date().toISOString()
      };
      
      const response = await client.post('/api/posts', post);
      return response.data;
    } catch (err) {
      return rejectWithValue(err.response.data);
    }
  }
);

export const updatePost = createAsyncThunk(
  'posts/updatePost',
  async ({ id, ...updates }, { rejectWithValue }) => {
    try {
      const response = await client.patch(`/api/posts/${id}`, updates);
      return response.data;
    } catch (err) {
      return rejectWithValue(err.response.data);
    }
  }
);

// Slice
const postsSlice = createSlice({
  name: 'posts',
  initialState: {
    items: [],
    status: 'idle', // 'idle' | 'loading' | 'succeeded' | 'failed'
    error: null,
    currentRequestId: undefined // For handling race conditions
  },
  reducers: {
    postUpdated: (state, action) => {
      const { id, changes } = action.payload;
      const existingPost = state.items.find(post => post.id === id);
      if (existingPost) {
        Object.assign(existingPost, changes);
      }
    },
    reactionAdded: (state, action) => {
      const { postId, reaction } = action.payload;
      const existingPost = state.items.find(post => post.id === postId);
      if (existingPost) {
        existingPost.reactions[reaction]++;
      }
    }
  },
  extraReducers: (builder) => {
    builder
      // Fetch posts
      .addCase(fetchPosts.pending, (state, action) => {
        if (state.status === 'idle') {
          state.status = 'loading';
          state.currentRequestId = action.meta.requestId;
        }
      })
      .addCase(fetchPosts.fulfilled, (state, action) => {
        if (
          state.status === 'loading' &&
          state.currentRequestId === action.meta.requestId
        ) {
          state.status = 'succeeded';
          state.items = action.payload;
          state.currentRequestId = undefined;
        }
      })
      .addCase(fetchPosts.rejected, (state, action) => {
        if (
          state.status === 'loading' &&
          state.currentRequestId === action.meta.requestId
        ) {
          state.status = 'failed';
          state.error = action.error.message;
          state.currentRequestId = undefined;
        }
      })
      // Add new post
      .addCase(addNewPost.fulfilled, (state, action) => {
        state.items.push(action.payload);
      })
      // Update post
      .addCase(updatePost.fulfilled, (state, action) => {
        const { id } = action.payload;
        const existingPost = state.items.find(post => post.id === id);
        if (existingPost) {
          Object.assign(existingPost, action.payload);
        }
      })
      // Handle all rejected cases
      .addMatcher(
        (action) => action.type.endsWith('/rejected'),
        (state, action) => {
          state.error = action.payload || action.error.message;
        }
      );
  }
});

export const { postUpdated, reactionAdded } = postsSlice.actions;

export default postsSlice.reducer;

// Selectors
export const selectAllPosts = state => state.posts.items;
export const selectPostById = (state, postId) => 
  state.posts.items.find(post => post.id === postId);

export const selectPostsByUser = createSelector(
  [selectAllPosts, (state, userId) => userId],
  (posts, userId) => posts.filter(post => post.author === userId)
);

// Memoized selector with multiple inputs
export const selectFilteredPosts = createSelector(
  [
    selectAllPosts,
    state => state.posts.searchTerm,
    state => state.posts.filter
  ],
  (posts, searchTerm, filter) => {
    let filteredPosts = posts;
    
    if (searchTerm) {
      filteredPosts = filteredPosts.filter(post =>
        post.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
        post.content.toLowerCase().includes(searchTerm.toLowerCase())
      );
    }
    
    if (filter !== 'all') {
      filteredPosts = filteredPosts.filter(post => post.category === filter);
    }
    
    return filteredPosts;
  }
);
            

Real-World Example: E-commerce Cart


// features/cart/cartSlice.js
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import { client } from '../../api/client';

// Async thunks
export const fetchCart = createAsyncThunk(
  'cart/fetchCart',
  async (_, { getState }) => {
    const token = getState().auth.token;
    const response = await client.get('/api/cart', {
      headers: { Authorization: `Bearer ${token}` }
    });
    return response.data;
  }
);

export const addToCart = createAsyncThunk(
  'cart/addToCart',
  async ({ productId, quantity = 1 }, { getState, rejectWithValue }) => {
    try {
      const token = getState().auth.token;
      const response = await client.post(
        '/api/cart/items',
        { productId, quantity },
        { headers: { Authorization: `Bearer ${token}` } }
      );
      return response.data;
    } catch (err) {
      return rejectWithValue(err.response.data);
    }
  }
);

export const updateCartItem = createAsyncThunk(
  'cart/updateItem',
  async ({ itemId, quantity }, { getState, rejectWithValue }) => {
    try {
      const token = getState().auth.token;
      const response = await client.patch(
        `/api/cart/items/${itemId}`,
        { quantity },
        { headers: { Authorization: `Bearer ${token}` } }
      );
      return response.data;
    } catch (err) {
      return rejectWithValue(err.response.data);
    }
  }
);

export const removeFromCart = createAsyncThunk(
  'cart/removeItem',
  async (itemId, { getState }) => {
    const token = getState().auth.token;
    await client.delete(`/api/cart/items/${itemId}`, {
      headers: { Authorization: `Bearer ${token}` }
    });
    return itemId;
  }
);

export const checkout = createAsyncThunk(
  'cart/checkout',
  async (paymentDetails, { getState, dispatch, rejectWithValue }) => {
    try {
      const token = getState().auth.token;
      const response = await client.post(
        '/api/checkout',
        paymentDetails,
        { headers: { Authorization: `Bearer ${token}` } }
      );
      
      // Clear cart after successful checkout
      dispatch(clearCart());
      
      return response.data;
    } catch (err) {
      return rejectWithValue(err.response.data);
    }
  }
);

// Cart slice
const cartSlice = createSlice({
  name: 'cart',
  initialState: {
    items: [],
    status: 'idle',
    error: null,
    total: 0,
    itemCount: 0,
    lastUpdated: null
  },
  reducers: {
    clearCart: (state) => {
      state.items = [];
      state.total = 0;
      state.itemCount = 0;
      state.lastUpdated = new Date().toISOString();
    },
    updateItemQuantity: (state, action) => {
      const { itemId, quantity } = action.payload;
      const item = state.items.find(item => item.id === itemId);
      if (item && quantity > 0) {
        item.quantity = quantity;
        cartSlice.caseReducers.calculateTotals(state);
      }
    },
    calculateTotals: (state) => {
      state.total = state.items.reduce((sum, item) => 
        sum + (item.price * item.quantity), 0
      );
      state.itemCount = state.items.reduce((sum, item) => 
        sum + item.quantity, 0
      );
    }
  },
  extraReducers: (builder) => {
    builder
      // Fetch cart
      .addCase(fetchCart.pending, (state) => {
        state.status = 'loading';
      })
      .addCase(fetchCart.fulfilled, (state, action) => {
        state.status = 'succeeded';
        state.items = action.payload.items;
        state.total = action.payload.total;
        state.itemCount = action.payload.itemCount;
        state.lastUpdated = new Date().toISOString();
      })
      .addCase(fetchCart.rejected, (state, action) => {
        state.status = 'failed';
        state.error = action.error.message;
      })
      // Add to cart
      .addCase(addToCart.pending, (state) => {
        state.status = 'loading';
      })
      .addCase(addToCart.fulfilled, (state, action) => {
        state.status = 'succeeded';
        const existingItem = state.items.find(
          item => item.productId === action.payload.productId
        );
        
        if (existingItem) {
          existingItem.quantity += action.payload.quantity;
        } else {
          state.items.push(action.payload);
        }
        
        cartSlice.caseReducers.calculateTotals(state);
        state.lastUpdated = new Date().toISOString();
      })
      .addCase(addToCart.rejected, (state, action) => {
        state.status = 'failed';
        state.error = action.payload || action.error.message;
      })
      // Update cart item
      .addCase(updateCartItem.fulfilled, (state, action) => {
        const item = state.items.find(item => item.id === action.payload.id);
        if (item) {
          item.quantity = action.payload.quantity;
          cartSlice.caseReducers.calculateTotals(state);
          state.lastUpdated = new Date().toISOString();
        }
      })
      // Remove from cart
      .addCase(removeFromCart.fulfilled, (state, action) => {
        state.items = state.items.filter(item => item.id !== action.payload);
        cartSlice.caseReducers.calculateTotals(state);
        state.lastUpdated = new Date().toISOString();
      })
      // Checkout
      .addCase(checkout.pending, (state) => {
        state.status = 'loading';
      })
      .addCase(checkout.fulfilled, (state) => {
        state.status = 'succeeded';
        // Cart will be cleared by the dispatch in the thunk
      })
      .addCase(checkout.rejected, (state, action) => {
        state.status = 'failed';
        state.error = action.payload || action.error.message;
      });
  }
});

export const { clearCart, updateItemQuantity } = cartSlice.actions;

export default cartSlice.reducer;

// Selectors
export const selectCartItems = state => state.cart.items;
export const selectCartTotal = state => state.cart.total;
export const selectCartItemCount = state => state.cart.itemCount;
export const selectCartStatus = state => state.cart.status;

export const selectCartItemById = (state, itemId) =>
  state.cart.items.find(item => item.id === itemId);

// Component using the cart slice
const ShoppingCart = () => {
  const dispatch = useDispatch();
  const cartItems = useSelector(selectCartItems);
  const cartTotal = useSelector(selectCartTotal);
  const cartStatus = useSelector(selectCartStatus);
  
  useEffect(() => {
    if (cartStatus === 'idle') {
      dispatch(fetchCart());
    }
  }, [cartStatus, dispatch]);
  
  const handleRemoveItem = (itemId) => {
    dispatch(removeFromCart(itemId));
  };
  
  const handleUpdateQuantity = (itemId, quantity) => {
    dispatch(updateCartItem({ itemId, quantity }));
  };
  
  const handleCheckout = async () => {
    try {
      const result = await dispatch(checkout(paymentDetails)).unwrap();
      // Navigate to confirmation page
      navigate(`/order/${result.orderId}`);
    } catch (err) {
      // Handle error
      toast.error(err.message);
    }
  };
  
  if (cartStatus === 'loading') return <LoadingSpinner />;
  if (cartStatus === 'failed') return <ErrorMessage />;
  
  return (
    <div className="shopping-cart">
      {cartItems.map(item => (
        <CartItem
          key={item.id}
          item={item}
          onRemove={handleRemoveItem}
          onUpdateQuantity={handleUpdateQuantity}
        />
      ))}
      <div className="cart-total">
        Total: ${cartTotal.toFixed(2)}
      </div>
      <button onClick={handleCheckout}>
        Checkout
      </button>
    </div>
  );
};
            

Advanced Patterns and Tips

1. Handling Loading States


// Generic loading state handler
const withLoadingState = (builder, asyncThunk, sliceName) => {
  builder
    .addCase(asyncThunk.pending, (state) => {
      state.status = 'loading';
      state.error = null;
    })
    .addCase(asyncThunk.fulfilled, (state) => {
      state.status = 'succeeded';
    })
    .addCase(asyncThunk.rejected, (state, action) => {
      state.status = 'failed';
      state.error = action.payload || action.error.message;
    });
};

// Usage in a slice
const userSlice = createSlice({
  name: 'user',
  initialState: {
    data: null,
    status: 'idle',
    error: null
  },
  reducers: {},
  extraReducers: (builder) => {
    withLoadingState(builder, fetchUser);
    builder.addCase(fetchUser.fulfilled, (state, action) => {
      state.data = action.payload;
    });
  }
});
            

2. Optimistic Updates


const todosSlice = createSlice({
  name: 'todos',
  initialState: { items: [] },
  reducers: {
    todoToggled: (state, action) => {
      const todo = state.items.find(t => t.id === action.payload);
      if (todo) {
        todo.completed = !todo.completed;
      }
    }
  }
});

// Optimistic update thunk
export const toggleTodoOptimistic = (todoId) => async (dispatch, getState) => {
  // Optimistically update UI
  dispatch(todoToggled(todoId));
  
  try {
    // Make API call
    await api.toggleTodo(todoId);
  } catch (error) {
    // Revert on error
    dispatch(todoToggled(todoId));
    throw error;
  }
};
            

3. Request Deduplication


// Prevent duplicate requests
const pendingRequests = new Map();

export const fetchUserOnce = createAsyncThunk(
  'users/fetchOnce',
  async (userId, { rejectWithValue }) => {
    // Check if request is already pending
    if (pendingRequests.has(userId)) {
      return pendingRequests.get(userId);
    }
    
    // Create new request
    const request = api.fetchUser(userId)
      .then(response => {
        pendingRequests.delete(userId);
        return response;
      })
      .catch(error => {
        pendingRequests.delete(userId);
        throw error;
      });
    
    pendingRequests.set(userId, request);
    return request;
  }
);
            

4. Slice Factories


// Create reusable slice factory
const createCrudSlice = (name, apiEndpoint) => {
  const fetchItems = createAsyncThunk(
    `${name}/fetchItems`,
    async () => {
      const response = await api.get(apiEndpoint);
      return response.data;
    }
  );
  
  const addItem = createAsyncThunk(
    `${name}/addItem`,
    async (item) => {
      const response = await api.post(apiEndpoint, item);
      return response.data;
    }
  );
  
  return createSlice({
    name,
    initialState: {
      items: [],
      status: 'idle',
      error: null
    },
    reducers: {},
    extraReducers: (builder) => {
      builder
        .addCase(fetchItems.fulfilled, (state, action) => {
          state.items = action.payload;
        })
        .addCase(addItem.fulfilled, (state, action) => {
          state.items.push(action.payload);
        });
    }
  });
};

// Usage
const productsSlice = createCrudSlice('products', '/api/products');
const categoriesSlice = createCrudSlice('categories', '/api/categories');
            

Testing Slices and Async Thunks


// Testing a slice
import reducer, { increment, decrement } from './counterSlice';

describe('counter reducer', () => {
  it('should handle initial state', () => {
    expect(reducer(undefined, { type: 'unknown' })).toEqual({
      value: 0,
      status: 'idle'
    });
  });

  it('should handle increment', () => {
    const actual = reducer({ value: 3 }, increment());
    expect(actual.value).toEqual(4);
  });

  it('should handle decrement', () => {
    const actual = reducer({ value: 3 }, decrement());
    expect(actual.value).toEqual(2);
  });
});

// Testing async thunks
import configureStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import { fetchPosts } from './postsSlice';
import api from '../../api/client';

jest.mock('../../api/client');

const middlewares = [thunk];
const mockStore = configureStore(middlewares);

describe('posts async actions', () => {
  beforeEach(() => {
    jest.resetAllMocks();
  });

  it('creates FETCH_POSTS_FULFILLED when fetching posts succeeds', async () => {
    const mockPosts = [{ id: 1, title: 'Test Post' }];
    api.get.mockResolvedValueOnce({ data: mockPosts });

    const expectedActions = [
      { type: fetchPosts.pending.type },
      { type: fetchPosts.fulfilled.type, payload: mockPosts }
    ];

    const store = mockStore({ posts: { items: [] } });
    await store.dispatch(fetchPosts());

    const actions = store.getActions();
    expect(actions[0].type).toEqual(expectedActions[0].type);
    expect(actions[1].type).toEqual(expectedActions[1].type);
    expect(actions[1].payload).toEqual(expectedActions[1].payload);
  });

  it('creates FETCH_POSTS_REJECTED when fetching posts fails', async () => {
    const errorMessage = 'Network Error';
    api.get.mockRejectedValueOnce(new Error(errorMessage));

    const store = mockStore({ posts: { items: [] } });
    await store.dispatch(fetchPosts());

    const actions = store.getActions();
    expect(actions[0].type).toEqual(fetchPosts.pending.type);
    expect(actions[1].type).toEqual(fetchPosts.rejected.type);
    expect(actions[1].error.message).toEqual(errorMessage);
  });
});
            

Best Practices

Do's

  • Keep slices focused on a single feature
  • Use extraReducers for async actions and cross-slice actions
  • Leverage prepare callbacks for consistent action payloads
  • Write selectors in the slice file
  • Handle all async states (pending, fulfilled, rejected)
  • Use TypeScript for better type safety

Don'ts

  • Don't put business logic in components
  • Don't access state directly - use selectors
  • Don't mutate state outside of createSlice
  • Don't dispatch actions in reducers
  • Don't make API calls directly in reducers

Performance Tips

  • Use createSelector for expensive computations
  • Normalize state shape to avoid deep updates
  • Consider using entity adapters for collections
  • Use the unwrap() method for better error handling

Practice Exercise

Task: Create a Comment System

Build a comment system slice with the following features:

  • Fetch comments for a post
  • Add new comments
  • Edit existing comments
  • Delete comments
  • Like/dislike comments
  • Handle nested replies

// TODO: Create comments slice with async thunks
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';

// TODO: Define async thunks
export const fetchComments = createAsyncThunk(/* ... */);
export const addComment = createAsyncThunk(/* ... */);
export const updateComment = createAsyncThunk(/* ... */);
export const deleteComment = createAsyncThunk(/* ... */);
export const toggleCommentLike = createAsyncThunk(/* ... */);

// TODO: Create the slice
const commentsSlice = createSlice({
  name: 'comments',
  initialState: {
    items: [],
    status: 'idle',
    error: null
  },
  reducers: {
    // Add any synchronous actions here
  },
  extraReducers: (builder) => {
    // Handle async actions
  }
});

// TODO: Export actions and selectors
export const selectCommentsByPost = (state, postId) => /* ... */;
export const selectCommentReplies = (state, parentId) => /* ... */;

// TODO: Create components that use this slice
const CommentList = ({ postId }) => {
  // Implement component
};

const CommentForm = ({ postId, parentId }) => {
  // Implement component
};
            

Additional Resources