Redux state mutation despite making state copy with spread operator

Viewed 733

Why I getting a state mutation alert when I'm making a copy of object with spread operator ? I'm trying to make a shop cart with redux. When adding an element or decrese its quantity I dispatch an action that update de state. In my reducer if product is not added yet, I return a new copy of state array with the new object. If it is already added, I iterate with map (that returns a new array) and with a copy of product that I'm increasing and its qty property increased. The problem is with this property What is the correct way to update this property without mutating the state?

reducer

case types.ADD_TO_CART_SUCCESS:
  const isAdded = state.cart.find(item => item.sku === action.product.sku);
  if (!isAdded) action.product.qty = 1;
  return {
    ...state,
    cart: !isAdded
      ? [action.product, ...state.cart]
      : state.cart.map(item =>
          item.sku === action.product.sku
            ? { ...item, qty: ++item.qty }
            : item
        ),
  };

Violation warning

Invariant Violation: A state mutation was detected inside a dispatch, in the path: `user.cart.0.qty`. Take a look at the reducer(s) handling the action {"type":"ADD_TO_CART_SUCCESS"....}
3 Answers

You are mutating the current item. Pre/post incremementing/decrementing will mutate the current object reference it operates on.

case types.ADD_TO_CART_SUCCESS:
  const isAdded = state.cart.find(item => item.sku === action.product.sku);
  if (!isAdded) action.product.qty = 1;
  return {
    ...state,
    cart: !isAdded
      ? [action.product, ...state.cart]
      : state.cart.map(item =>
          item.sku === action.product.sku
            ? { ...item, qty: ++item.qty } // <-- This mutates the current item.qty
            : item
        ),
  };

You can however return an incremented value, current item.qty + 1

case types.ADD_TO_CART_SUCCESS:
  const isAdded = state.cart.find(item => item.sku === action.product.sku);
  if (!isAdded) action.product.qty = 1;
  return {
    ...state,
    cart: !isAdded
      ? [action.product, ...state.cart]
      : state.cart.map(item =>
          item.sku === action.product.sku
            ? { ...item, qty: item.qty + 1 }
            : item
        ),
  };

I suspect your code functions as expected, since you do correctly replace the entire cart array and the item you mutate is the item you want to update anyway, but this is indicative that your reducer may not a pure function, i.e. the previous state should still equal the previous state in all aspects after the reducer computes the next state.

It looks like you're using our official Redux Toolkit package, which will warn about accidental state mutations by default. That's great, because it clearly caught a mutation in this case.

But, what's even better is that Redux Toolkit uses Immer inside to let you write "mutating" state updates in reducers!. That means that your reducer logic can be simplified down to this:

const cartSlice = createSlice({
  name: 'cart',
  initialState,
  reducers: {
    addToCartSuccess(state, action) {
      const item = state.cart.find(item => item.sku === action.payload.product.sku);
      if (item) {
        item.qty++;
      } else {
        action.product.qty = 1;
        state.cart.unshift(action.payload.product);
      }
    }
  }
})

I made something like this a couple of times, try this code:

const addItemToCart = (cartItems, cartItemToAdd) => {
  const existingCartItem = cartItems.find(
    (cartItem) => cartItem.id === cartItemToAdd.id
  );

  if (existingCartItem) {
    return cartItems.map((cartItem) =>
      cartItem.id === cartItemToAdd.id
        ? { ...cartItem, quantity: cartItem.quantity + 1 }
        : cartItem
    );
  }

  return [...cartItems, { ...cartItemToAdd, quantity: 1 }];
};
Related