Search code examples
reactjsfirebasereact-reduxredux-toolkit

Redux toolkit state does not change after a dispatch until the next render


I was building basic authentication flow using RTK and Firebase in React and in my Login Page:


export default function LoginPage() {
    //redux states and dispatch
    const isLoading = useAppSelector(state => state.auth.loading)
    const user = useAppSelector(state => state.auth.user)
    // const error = useAppSelector(state => state.auth.error)
    const dispatch = useAppDispatch()
    const navigate = useNavigate()
    const [errorMessage, setErrorMessage] = useState("")
    const [isPassVisible, setIsPassVisible] = useState<boolean>(false)
    const [formData, setFormData] = useState<LoginRequestType>({
        email: "",
        password: ""
    })

    const handleFormInput = (event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
        const { name, value } = event.target;
        setFormData((prevState) => {
            return {
                ...prevState,
                [name]: value
            }
        });
    }

    const onSubmitAction = async (e: React.ChangeEvent<HTMLFormElement>) => {
        e.preventDefault()
        setErrorMessage("")
        try {
            await dispatch(loginUser(formData))
            if (!user.accessToken) {
                setErrorMessage("Login error: check your email and password and try again")
            }else{
                toast.success('Login Successful', {
                    position: "top-center",
                    autoClose: 5000,
                    hideProgressBar: false,
                    closeOnClick: true,
                    pauseOnHover: true,
                    draggable: true,
                    progress: undefined,
                    theme: "dark",
                    transition: Bounce,
                });
                navigate("/user")
            }
        } catch (error) {
            toast.error('An error occured, please try again later!', {
                position: "top-center",
                autoClose: 5000,
                hideProgressBar: false,
                closeOnClick: true,
                pauseOnHover: true,
                draggable: true,
                progress: undefined,
                theme: "dark",
                transition: Bounce,
            });
        }
    }
    return (
        <div className="p-3 flex flex-col gap-12 place-content-center place-items-center">
            <div>
                <h1 className="font-merriweather font-medium text-2xl lg:text-4xl text-center">Login</h1>
                <h1 className="font-merriweather text-sm xl:text-xl text-center mt-5">Login to get started with your course or get enrolled</h1>
            </div>

            <form onSubmit={onSubmitAction} className="border-2  border-primary-100 bg-primary-50 p-8 lg:p-10 mb-16 rounded-3xl">
                {/* EMAIL AND PHONE CONTAINER */}
                <div className="flex flex-col lg:gap-6">
                    {/* Email */}
                    <div>
                        <div className="text-xs text-gray font-bold">Email</div>
                        <input required name="email" onChange={handleFormInput} type="email" className="border-2 bg-transparent border-gray rounded-xl py-2 px-6 mt-2 w-full" />
                    </div>
                    {/* Password */}
                    <div>
                        <div className="text-xs text-gray font-bold mt-2">Password</div>
                        <div className="relative">
                            <input
                                required
                                autoComplete="off"
                                name="password"
                                onChange={handleFormInput}
                                type={`${isPassVisible ? "text" : "password"}`}
                                maxLength={15}
                                className="border-2 bg-transparent border-gray rounded-xl py-2 px-6 w-full mt-2"
                            />
                            <button type="button" onClick={() => setIsPassVisible(p => !p)} className="text-primary-400 text-2xl absolute inset-y-0 right-3 top-[0.55rem]">
                                {isPassVisible ? <FaRegEye /> : <FaRegEyeSlash />}
                            </button>
                        </div>
                    </div>
                </div>
                {
                    errorMessage && <div className="max-w-[270px] text-red-500 mt-3 text-wrap">{errorMessage}</div> 
                }
                <div className="flex place-content-center mt-6">
                    {
                        !!isLoading ?
                            <div className="flex place-content-center gap-10 place-items-center">
                                <PacmanLoader size={15} speedMultiplier={2} color="#2b966f" />
                                <div className="text-primary-600 font-semibold">Logging you in!</div>
                            </div>
                            :
                            <Button type="submit" variant="default" text="Login" />
                    }
                </div>

            </form>
        </div>
    )
}

After dispatching the async login action, I check for the user access token and based on that I navigate the user to the appropriate route. However, the state does not get updated until the next render. This is my auth slice:


const cookies = new Cookies()

type AuthStateType = {
    loading: boolean,
    user: User,
    error: any,
}


type RegisterPayloadType = {
    email: string, 
    password: string, 
    name: string, 
    type: ChipType, 
    orgName: string,
    date: string
}


let initialState: AuthStateType = {
    loading: false,
    user: {
        name: "",
        email: "",
        //Default state of type 
        type: "normal",
        accessToken: "",
        orgName: "",
        isEmailVerified: false,
    },
    error: null
}
const storedToken = cookies.get('token') 
const storedData = cookies.get('userData')
if(storedToken && storedData){
    initialState.user = {
        ...initialState.user,
        accessToken: storedToken,
        ...storedData
    }
}


// Registering Users
export const registerUser = createAsyncThunk("auth/register", 
    async (payload: RegisterPayloadType) => {
    const userCredentials = await createUserWithEmailAndPassword(auth, payload.email, payload.password)
    const user = userCredentials.user
    const token = await user.getIdToken()
    const isEmailVerified = user.emailVerified
    const userData: UserFBDocType = {
        uid: user.uid,
        orgName:payload.orgName,
        email: payload.email,
        name: payload.name,
        type: payload.type,
        date: payload.date,
    }
    await setDoc(doc(db, "users", user.uid), userData)
    await sendEmailVerification(user)    
    return { userData, token, isEmailVerified }
})

//Logging In User
export const loginUser = createAsyncThunk("auth/login",

    async (payload: LoginRequestType ) =>{
        try {
            await setPersistence(auth, browserLocalPersistence);
            const userCredentials = await signInWithEmailAndPassword(auth, payload.email, payload.password)
            const user = userCredentials.user
            const token = await user.getIdToken()
            //Getting the user data from the db: 
            const docRef = doc(db, "users", user.uid);
            const userDocSnap = await getDoc(docRef)

            let userData
            let error = null
            if (userDocSnap.exists()) {
                userData = userDocSnap.data()
            } else {
                error = "User does not exist on the db"
            }

            cookies.set("token", token)
            if(userData){
                cookies.set("userData", JSON.stringify(userData))
            }

            return { user, token, userData, error }
        } catch (error) {
            throw error
       }
    }

)

const authSlice = createSlice({
    name: 'auth',
    initialState: initialState,
    reducers:{
        signOut:(state) =>{

            state.user = {
                ...state.user,
                accessToken:"",
                email: "",
                name:"",
                orgName: "",
                type:"normal"
            }
            cookies.remove('token');
            cookies.remove('userData');
        }
    },

    extraReducers: (builder) =>{
        //CASES FOR SIGN UP
        builder.addCase(registerUser.pending, (state) =>{
            state.loading = true
        })
        builder.addCase(registerUser.fulfilled, (state, { payload }) =>{
            state.loading = false
            state.user = {
                ...state.user,
                accessToken: payload.token,
                email: payload.userData.email,
                name: payload.userData.name,
                orgName: payload.userData.orgName,
                isEmailVerified: payload.isEmailVerified 
            }
        })
        builder.addCase(registerUser.rejected, (state, action) =>{
            state.loading = false
            state.error = action.error.message
        })
        //CASES FOR LOGIN
        builder.addCase(loginUser.pending, (state) => {
            state.loading = true
        })
        builder.addCase(loginUser.fulfilled, (state, { payload }) => {
            state.loading = false
            state.error = null
            state.user = {
                ...state.user,
                accessToken: payload.token,
                email: payload.userData?.email,
                name: payload.userData?.name,
                orgName: payload.userData?.orgName,
                isEmailVerified: payload.userData?.isEmailVerified
            }
        })
        builder.addCase(loginUser.rejected, (state, action) => {
            state.loading = false
            state.error = action.error.message
        })
    }
})

export const authActions = authSlice.actions
export default authSlice.reducer

Shouldn't the state be updated as soon as the loginUser function is fulfilled?


Solution

  • The state, for the most part, is actually updated in "real-time" and will trigger a component rerender (with the updated value), but the issue here is that your onSubmitAction is referencing a stale closure over the selected user state from the outer scope from the time the callback is called, the user value won't ever be a different value within the callback.

    You can await and unwrap the resolved value in the callback though. See Handling Thunk Results for more details.

    const onSubmitAction = async (e: React.ChangeEvent<HTMLFormElement>) => {
      e.preventDefault();
      setErrorMessage("");
    
      try {
        const { user } = await dispatch(loginUser(formData)).unwrap();
    
        if (!user.accessToken) {
          setErrorMessage("Login error: check your email and password and try again")
        } else {
          toast.success('Login Successful', {
            ....
          });
          navigate("/user");
        }
      } catch (error) {
        toast.error('An error occurred, please try again later!', {
          ....
        });
      }
    }
    

    The thunks should also reject with the error value instead of re-throwing it.

    Example:

    export const loginUser = createAsyncThunk("auth/login",
      async (payload: LoginRequestType, thunkApi) => {
        try {
          await setPersistence(auth, browserLocalPersistence);
          const userCredentials = await signInWithEmailAndPassword(
            auth,
            payload.email,
            payload.password
          );
    
          const user = userCredentials.user;
          const token = await user.getIdToken();
    
          // Getting the user data from the db: 
          const docRef = doc(db, "users", user.uid);
          const userDocSnap = await getDoc(docRef);
    
          if (!userDocSnap.exists()) {
            throw new Error("User does not exist on the db");
          }
    
          const userData = userDocSnap.data();
    
          cookies.set("token", token);
    
          if (userData) {
            cookies.set("userData", JSON.stringify(userData));
          }
    
          return { user, token, userData };
        } catch (error) {
          thunkApi.rejectWithValue(error);
       }
    });
    
    builder.addCase(loginUser.rejected, (state, action) => {
      state.loading = false
      state.error = action.payload.message
    })