react-router-dom navigate为何在dispatch完成前执行?
问题描述
我自定义了useAPIRequest钩子用于处理API请求,在Login组件中使用该钩子完成登录请求后,通过userDispatch更新用户状态并存入localStorage,随后调用navigate跳转到经销商选择页。但实际跳转时,localStorage中还没有用户数据,请问这是什么原因?
相关代码
自定义useAPIRequest钩子
import { useNavigate } from "react-router-dom"; import { routes } from "src/routes/routes"; import { useState } from "react"; import { useLocalStorage } from "src/hooks/useLocaleStorage"; import { useTranslation } from "react-i18next"; interface requestParameters { url: string; // eslint-disable-next-line onSuccess?: (responseData: any) => void; // eslint-disable-next-line onError?: (responseData: any) => void; } // eslint-disable-next-line interface getRequestParameters extends requestParameters {} interface postRequestParameters extends requestParameters{ // eslint-disable-next-line body?: any; } interface fetchRequestParameters extends requestParameters { method: string; //eslint-disable-next-line body?: any; } export const useAPIRequest = () => { const [isLoading, setIsLoading] = useState<boolean>(false); const [success, setSuccess] = useState<boolean | undefined>(undefined); // eslint-disable-next-line const [data, setData] = useState<any>(undefined); const [error, setError] = useState<string | undefined>(undefined); const navigate = useNavigate(); const { removeFromStorage } = useLocalStorage(); const { t } = useTranslation(); const getErrorByID = (errorID: string) => { switch (errorID){ case "INVALID_CREDENTIALS": return t("There is no user found with the given credentials") default: return t('The data could not be fetched.') } } const fetchRequest = async (parameters: fetchRequestParameters) => { const { url, method, body = undefined, onSuccess = undefined, onError = undefined } = parameters; setIsLoading(true); const requestOptions: RequestInit = { method: method, headers: { "Accept": "application/json", "Content-Type": "application/json" } }; if (body !== undefined) { requestOptions.body = JSON.stringify(body); } try { const response = await fetch(url, requestOptions); const responseData = await response.json(); if (response?.status === 403) { // Not authenticated - clear localstorage and navigate to login page removeFromStorage('user'); navigate(routes.LOGIN); // Show notification } setSuccess(response.ok); setData(response.ok ? responseData : undefined) setError(response.ok ? undefined : getErrorByID(responseData.id)); setIsLoading(false) // eslint-disable-next-line no-debugger debugger; if(response.ok && onSuccess) onSuccess(responseData) if(!response.ok && onError) onError(responseData) // eslint-disable-next-line } catch (e: any) { setSuccess(false); setData(undefined) setError(t("An error occurred. Fetch request not executed.")); setIsLoading(false) if(onError) onError(e) } } return { isLoading, success, data, error, getRequest: (parameters: getRequestParameters): Promise<void> => { return fetchRequest({ url: parameters.url, method: "GET", onSuccess: parameters?.onSuccess }) }, postRequest: (parameters: postRequestParameters): Promise<void> => { return fetchRequest({ url: parameters.url, method: "POST", body: parameters?.body, onSuccess: parameters?.onSuccess }) } } }
UserReducer代码
export function UserReducer( UserState: UserState | never, action: UserAction ): UserState | never { switch (action.type) { case UserActionKind.LOG_IN: { const newState = { ...UserState, id: action.payload.id, isAuthenticated: true, info: { ...UserState.info, firstName: action.payload.firstName, lastName: action.payload.lastName }, } localStorage.setItem('user', JSON.stringify(newState)) return newState; } default: { throw Error("Unknown action: " + action.type); } } }
UserContext相关代码
export const UserDispatchContext = createContext< Dispatch<UserAction> | undefined >(undefined); export function UserProvider({ children }: { children: React.ReactNode }) { const userFromStorage = localStorage.getItem('user'); const userState = userFromStorage ? JSON.parse(userFromStorage) : initialUserState; const [user, dispatch] = useReducer(UserReducer, userState); console.log('User state: ', user); return ( <UserContext.Provider value={user}> <UserDispatchContext.Provider value={dispatch}> {children} </UserDispatchContext.Provider> </UserContext.Provider> ); } export function useUserDispatch() { const context = useContext(UserDispatchContext); if (context === undefined) { throw new Error("UserDispatchContext must be within provider"); } return context; }
完整Login组件代码
import React, { useEffect } from "react"; import backgroundImage from "src/assets/login_background.webp"; import { useUI } from "src/state/uiContext"; import { UserActionKind, useUserDispatch } from "src/state/userContext"; import { API_ENDPOINT } from "src/settings"; import { useLocation, useNavigate } from "react-router-dom"; import { routes } from "src/routes/routes"; import LoginForm from "src/routes/login/components/loginForm"; import { useLocalStorage } from "src/hooks/useLocaleStorage"; import { useAPIRequest } from "src/hooks/useAPIRequest"; import { message } from "antd"; export default function Login() { const { isLoading: submitting, isFetching, success, data, error, postRequest } = useAPIRequest(); const { getFromStorage } = useLocalStorage(); const isAuthenticated = getFromStorage('user')?.isAuthenticated const ui = useUI(); const userDispatch = useUserDispatch(); const navigate = useNavigate(); const location = useLocation() const notice = location?.state?.message; // Effect to navigate to dealers selector page if user is authenticated useEffect(() => { if(isAuthenticated){ navigate(routes.DEALER_SELECTOR) } }, [isAuthenticated]) useEffect(() => { if(success && data && !isFetching){ userDispatch({ type: UserActionKind.LOG_IN, payload: { id: data.id, firstName: data.first_name, lastName: data.last_name, }, }) // Navigate to dealers selector page navigate(routes.DEALER_SELECTOR, { state: { isAuthenticated: true } }) } }, [isFetching, success, data]); // Effect to show a toast notice if user is redirected from the protected page useEffect(() => { if(notice){ message.info(notice) window.history.replaceState({}, document.title) } }, []); const handleSubmit = async (email: string, password: string) => { // handle the form submission await postRequest({ url: `${API_ENDPOINT}/auth/log-in`, body: { username: email, password: password } }) } return ( <div className="align-center flex h-screen w-screen justify-center" style={backgroundStyle} > <div className="flex h-screen w-1/3 flex-col items-center justify-center bg-slate-50/75 p-4"> <img src={ui.assets.logo} className="mb-12 h-fit w-1/2" alt="Logo"/> <LoginForm submitting={submitting} error={error} handleSubmit={handleSubmit}/> </div> </div> ); } const backgroundStyle = { background: `url(${backgroundImage})`, backgroundPosition: "center", backgroundSize: "cover", backgroundRepeat: "no-repeat", width: "100vw", height: "100vh", };
原因分析
问题核心在于React useEffect的异步执行时机:
- 登录请求成功后,
useAPIRequest中的setSuccess和setData触发组件重新渲染,但React状态更新是异步批量处理的。 - 组件渲染完成后,对应的
useEffect才会被加入异步任务队列等待执行。 - 若目标页面存在路由守卫,会在
useEffect执行前就检查localStorage,此时userDispatch还未触发reducer写入数据,导致守卫读取到空值。 - 即使没有路由守卫,
navigate同步触发导航后,目标组件可能在useEffect完成localStorage写入前就初始化,同样会读取到旧数据。
另外,Login组件中依赖isAuthenticated的useEffect无法响应localStorage变化,因为isAuthenticated是组件初始化时一次性读取的,并非响应式状态。
解决方案
方案1:使用请求成功回调替代useEffect监听
直接在postRequest的onSuccess回调中处理状态更新和导航,避免useEffect的异步延迟:
修改Login组件的handleSubmit函数:
const handleSubmit = async (email: string, password: string) => { await postRequest({ url: `${API_ENDPOINT}/auth/log-in`, body: { username: email, password: password }, onSuccess: (responseData) => { // 同步执行状态更新和localStorage写入 userDispatch({ type: UserActionKind.LOG_IN, payload: { id: responseData.id, firstName: responseData.first_name, lastName: responseData.last_name, }, }); // 写入完成后再导航 navigate(routes.DEALER_SELECTOR, { state: { isAuthenticated: true } }); } }) }
同时删除原来监听success和data的useEffect。
方案2:监听用户状态变化再导航
通过useUserContext获取用户状态,监听isAuthenticated的变化,确保状态更新完成后再导航:
首先在Login组件中引入useUserContext:
import { UserActionKind, useUserDispatch, useUserContext } from "src/state/userContext";
然后添加监听用户状态的useEffect:
const user = useUserContext(); useEffect(() => { if(user.isAuthenticated){ navigate(routes.DEALER_SELECTOR, { state: { isAuthenticated: true } }); } }, [user.isAuthenticated, navigate]);
同时修改原来的useEffect,只保留状态更新逻辑:
useEffect(() => { if(success && data && !isFetching){ userDispatch({ type: UserActionKind.LOG_IN, payload: { id: data.id, firstName: data.first_name, lastName: data.last_name, }, }); } }, [isFetching, success, data, userDispatch]);
这样只有当用户状态真正更新为已认证后,才会触发导航,确保localStorage已经写入数据。
内容的提问来源于stack exchange,提问作者Boky
相关产品推荐
相关产品推荐

