You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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的异步执行时机:

  1. 登录请求成功后,useAPIRequest中的setSuccess和setData触发组件重新渲染,但React状态更新是异步批量处理的。
  2. 组件渲染完成后,对应的useEffect才会被加入异步任务队列等待执行。
  3. 若目标页面存在路由守卫,会在useEffect执行前就检查localStorage,此时userDispatch还未触发reducer写入数据,导致守卫读取到空值。
  4. 即使没有路由守卫,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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.30 21:42:29