Search code examples
reactjsredux-toolkit

Error: Actions must be plain objects. Use custom middleware for async actions. Redux/Toolkit


When trying to add product to user favorites I am getting this error. Although the state of redux is getting updated properly. I have used Redux Thunk for Authentication, but I was trying to avoid adding to many extra reducers as the add a lot of code just for one function; and start using productService.js file.

userSlice.js

import {
  createAction,
  createSlice,
} from "@reduxjs/toolkit";
import {
  loginUser,
  registerUser,
} from "../actions/authActions";
import { getWithExpiry } from "../../utils/setStorage";

export const addFavoritesError = createAction(
  "addFavoritesError"
);

const savedUserInfo = getWithExpiry("userInfo")
  ? getWithExpiry("userInfo")
  : null;
const savedToken = getWithExpiry("userToken")
  ? getWithExpiry("userToken")
  : null;

const initialValue = {
  isLoading: false,
  userInfo: savedUserInfo,
  userToken: savedToken,
  error: null,
  success: false,
};

const authSlice = createSlice({
  name: "auth",
  initialState: initialValue,
  reducers: {
    login: (state) => {
      loginUser().then((res) => {
        // history.pushState('/');
      });
    },

    logOut: (state) => {
      return {
        ...state.initialState,
        isLoading: false,
        userInfo: null,
        userToken: null,
        error: null,
        success: false,
      };
    },
    signUp: (state) => {
      registerUser().then((res) => {
        // history.pushState('/');
      });
    },

    addFavorites: (state, action) => {
      return {
        ...state,
        userInfo: {
          ...state.userInfo,
          favorites: [...action.payload],
        },
      };
    },
  },
  extraReducers: {
    // 1 login user
    [loginUser.pending]: (state) => {
      state.isLoading = true;
      state.error = null;
    },

    [loginUser.fulfilled]: (state, { payload }) => {
      state.isLoading = false;
      state.success = true;
      state.userInfo = payload.data.user;
      state.userToken = payload.token;
    },

    [loginUser.rejected]: (state, { payload }) => {
      state.isLoading = false;
      state.error = payload;
    },

    // 2 register user

    [registerUser.pending]: (state) => {
      state.isLoading = true;
      state.error = null;
    },

    [registerUser.fulfilled]: (state, { payload }) => {
      state.isLoading = false;
      state.success = true;
      state.userInfo = payload;
      state.userToken = payload.token;
    },

    [registerUser.rejected]: (state, { payload }) => {
      state.isLoading = false;
      state.error = payload;
    },
  },
});

export const { logOut, addFavorites } = authSlice.actions;

export default authSlice.reducer

userService.js

import apiActions from "../../utils/api";
import { addFavorites, addFavoritesError } from "../slices/userSlice";

export const AddProdToFavorites = async (dispatch, user_id, prod_id) => {
  try {
    // api call
    const data = await apiActions.addProdToUserFav(user_id, prod_id);
    dispatch(addFavorites(data));
  } catch {
    dispatch(addFavoritesError());
  }
};`

ProductCard.js

import React, { useCallback, useState } from "react";
import { useDispatch} from "react-redux";
import { AddProdToFavorites} from "../../../store/services/userService";


function DestinationsDisplayCard(props) {
  const { userToken, name, price} = props;
  const [favored, setFavored] = useState(false);

  const dispatch = useDispatch();
  const addItemtoFav = useCallback(() => {
    setFavored((currrent) => !currrent);
    dispatch(AddProdToFavorites(dispatch, userToken, prodId));
  }, [favored]);

const removeItemFromFav = useCallback(() => {}, [favored]);

return (
  <div className="productCard">
        <p>{name}</p>
        <p>${price}</p>
        {userToken ? (
          <button
            onClick={favored ? removeItemFromFav : addItemtoFav}
          >
          </button>
        ) : null}
      </div>

)
}

authActions.js

import { createAsyncThunk } from "@reduxjs/toolkit";
import apiActions from "../../utils/api";
import { setWithExpiry } from "../../utils/setStorage";

export const loginUser = createAsyncThunk(
  "auth/login",
  async ({ email, password }, { rejectWithValue }) => {
    try {
      const data = await apiActions.loginUser(
        email,
        password
      );

      setWithExpiry("userToken", data.token, 900000);
      setWithExpiry("userInfo", data.data.user, 900000);

      return data;
    } catch (error) {
      if (error.response && error.response.data.message) {
        return rejectWithValue(error.response.data.message);
      } else {
        return rejectWithValue(error.message);
      }
    }
  }
);

export const registerUser = createAsyncThunk(
  "auth/register",
  async (
    { name, email, password },
    { rejectWithValue }
  ) => {
    console.log("thunk - register", name, email, password);
    try {
      const data = await apiActions.registerUser(
        name,
        email,
        password
      );
      localStorage.setItem("userToken", data.token);

      return data;
    } catch (error) {
      if (error.response && error.response.data.message) {
        return rejectWithValue(error.response.data.message);
      } else {
        return rejectWithValue(error.message);
      }
    }
  }
);

api.js

import axios from "axios";
const baseURL = "<<the url for api>>";

const apiActions = {
addProdToUserFav: async (user_id, prod_id) => {
    const res = await axios.post(
      `${baseURL}/v1/user/addFavorite`,
      {
        userId: user_id,
        prodId: prod_id,
      }
    );
    return res.data.data.user.favorites;
  },
// this endpoint returns a list of object [{product2}{product2}] (with name, price, discount, etc)
}

I expected that state.userInfo.favorites to update, which it does, but I am getting this error.

Uncaught Error: Actions must be plain objects. Use custom middleware for async actions.
VM41:1 Uncaught Error: Actions must be plain objects. Use custom middleware for async actions.
    at Object.performAction (<anonymous>:1:41504)
    at k (<anonymous>:3:1392)
    at s (<anonymous>:3:5102)
    at serializableStateInvariantMiddleware.ts:210:1
    at index.js:20:1
    at Object.dispatch (immutableStateInvariantMiddleware.ts:264:1)
    at dispatch (<anonymous>:6:7391)
    at DestinationsDisplay.card.jsx:23:1
    at HTMLUnknownElement.callCallback (react-dom.development.js:4164:1)
    at Object.invokeGuardedCallbackDev (react-dom.development.js:4213:1)

Solution

  • You have a few issues with the way you are trying to use regular reducer functions to handle asynchronous side-effects, and dispatching objects to the store that are neither action objects nor asynchronous action creators, e.g. Thunks.

    AddProdToFavorites is a regular function and should be rewritten to use Redux-Toolkit's createAsyncThunk.

    The suggested/recommended patter is to return directly the fetched data or rejected Promise with error value, and use the state slice's extraReducers to handle the state updates. Remember that with RTK you can write mutable state updates, you don't need to shallow copy all the state.

    import { createAsyncThunk } from "@reduxjs/toolkit";
    import apiActions from "../../utils/api";
    
    export const AddProdToFavorites = createAsyncThunk(
      "addProdToFavorites",
      async ({ user_id, prod_id }, thunkApi) => {
        try {
          // api call
          const data = await apiActions.addProdToUserFav(user_id, prod_id);
          return data;
        } catch(error) {
          return thunkApi.rejectWithValue(error);
        }
      },
    );
    
    const authSlice = createSlice({
      name: "auth",
      initialState: initialValue,
      reducers: {
        ...
        addFavorites: (state, action) => {
          state.userInfo.favorites = action.payload;
        },
      },
      extraReducers: builder => {
        ...
      },
    });
    

    or if you are really wanting to write and keep the extra actions, use the second argument to the thunk to access the dispatch function and dispatch your other actions to the store. Handle these actions in the regular reducers with the same mutable state update.

    import { createAsyncThunk } from "@reduxjs/toolkit";
    import apiActions from "../../utils/api";
    import { addFavorites, addFavoritesError } from "../slices/userSlice";
    
    export const AddProdToFavorites = createAsyncThunk(
      "addProdToFavorites",
      async ({ user_id, prod_id }, thunkApi) => {
        try {
          // api call
          const data = await apiActions.addProdToUserFav(user_id, prod_id);
          thunkApi.dispatch(addFavorites(data));
        } catch(error) {
          thunkApi.dispatch(addFavoritesError(error));
        }
      },
    );
    
    const authSlice = createSlice({
      name: "auth",
      initialState: initialValue,
      reducers: {
        ...
      },
      extraReducers: builder => {
        builder
          .addCase(AddProdToFavorites.fulfilled, (state, action) => {
            state.userInfo.favorites = action.payload;
          })
          ...
      },
    });
    

    Dispatching AddProdToFavorites action:

    function DestinationsDisplayCard({ userToken, name, price }) {
      const dispatch = useDispatch();
      
      const [favored, setFavored] = useState(false);
    
      const addItemtoFav = () => {
        setFavored((favored) => !favored);
        dispatch(AddProdToFavorites({ userToken, prodId }));
      };
    
      const removeItemFromFav = () => {};
    
      return (
        <div className="productCard">
          <p>{name}</p>
          <p>${price}</p>
          {userToken && (
            <button
              onClick={favored ? removeItemFromFav : addItemtoFav}
            >
              ...
            </button>
          }
        </div>
      );
    }
    

    In authSlice the login and signup actions/reducers are invalid. Reducer functions are to be pure, synchronous functions. These two actions should also be Thunks, and the UI should handle chaining or await-ing them to settle in order to issue the additional side-effect, e.g. the navigation. It looks like loginUser and registerUser are already RTK Thunks, so remove the login and signup actions/reducers from the authSlice and handle the asynchronous logic in the calling function.

    Example:

    const authSlice = createSlice({
      name: "auth",
      initialState: initialValue,
      reducers: {
        logOut: (state) => {
          return {
            ...state.initialState,
            isLoading: false,
            userInfo: null,
            userToken: null,
            error: null,
            success: false,
          };
        },
      },
      extraReducers: builder => {
        builder
          .addCase(AddProdToFavorites.fulfilled, (state, action) => {
            state.userInfo.favorites = action.payload;
          })
          .addCase(loginUser.pending, (state) => {
            state.isLoading = true;
            state.error = null;
          })
          .addCase(loginUser.fulfilled, (state, { payload }) => {
            state.isLoading = false;
            state.success = true;
            state.userInfo = payload.data.user;
            state.userToken = payload.token;
          })
          .addCase(loginUser.rejected, (state, { payload }) => {
            state.isLoading = false;
            state.error = payload;
          })
          .addCase(registerUser.pending, (state) => {
            state.isLoading = true;
            state.error = null;
          })
          .addCase(registerUser.fulfilled, (state, { payload }) => {
            state.isLoading = false;
            state.success = true;
            state.userInfo = payload;
            state.userToken = payload.token;
          })
          .addCase(registerUser.rejected, (state, { payload }) => {
            state.isLoading = false;
            state.error = payload;
          });
      },
    });
    

    Unwrap the returned resolved Promise to handle the fulfilled/rejected status.

    const SomeComponent = () => {
      const dispatch = useDispatch();
      const history = useHistory();
    
      const loginHandler = async () => {
        try {
          await dispatch(loginUser()).unwrap();
          history.push("/");
        } catch(error) {
          // handle login errors, etc...
        }
      };
    
      ...
    };
    

    or

    const SomeComponent = () => {
      const dispatch = useDispatch();
      const history = useHistory();
    
      const loginHandler = () => {
        dispatch(loginUser()).unwrap()
          .then(() => {
            history.push("/");
          })
          .catch((error) => {
            // handle login errors, etc...
          });
      };
    
      ...
    };