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

Redux状态突变报错求助:登录触发auth.access路径状态变更异常

认证模块登录状态突变问题排查

问题描述

点击登录按钮时弹出以下错误:

Uncaught Error: Invariant failed: A state mutation was detected between dispatches, in the path 'auth.access'. This may cause incorrect behavior.
at invariant (immutableStateInvariantMiddleware.ts:25:1)
at immutableStateInvariantMiddleware.ts:256:1
at Object.measureTime (utils.ts:10:1)
at Object.dispatch (immutableStateInvariantMiddleware.ts:249:1)
at dispatch (:1:55003)
at onClickLogIn (LogInPage.js:50:1)
at HTMLUnknownElement.callCallback (react-dom.development.js:4164:1)
at Object.invokeGuardedCallbackDev (react-dom.development.js:4213:1)
at invokeGuardedCallback (react-dom.development.js:4277:1)
at invokeGuardedCallbackAndCatchFirstError (react-dom.development.js:4291:1)

使用@reduxjs/toolkit开发,本以为它会自动处理reducer中的不可变操作,即使修改reducer确保完全以不可变方式更新状态,错误仍反复出现。已调试onClickLogIn()函数但未找到问题根源。

相关代码片段

store.js

import {configureStore} from "@reduxjs/toolkit";
import authReducer from './authSlice';
import requestReducer from './requestSlice';
import {getAccessToken, getRefreshToken, setAccessToken, setRefreshToken} from "../utils/token";
import * as apiCalls from '../api/apiCalls'
let preloadedState = {
    auth: {
        access: '',
        refresh: '',
        loggedIn: false,
    },
    request: {
        entities: [],
    }
};

const refreshToken = getRefreshToken();
if (refreshToken !== '')    {
    apiCalls.refresh({'refresh': refreshToken}).then(resp => {
        setAccessToken(resp.data.access);
        setRefreshToken(resp.data.refresh);
        preloadedState.auth.access = getAccessToken();
        preloadedState.auth.refresh = getRefreshToken();
        preloadedState.auth.loggedIn = true;
        apiCalls.setAuthHeader(resp.data.access);
        console.log('detect previous logged in: ', preloadedState);
    }).catch(err => {
        console.log('no auth / expired token in localstorage');
    });
}
const store = configureStore({
    reducer: {
        auth: authReducer,
        request:requestReducer,
    },
    preloadedState: preloadedState
});

const updateAuth = (state) => {
    const access = state.auth.access;
    const refresh = state.auth.refresh;
    setAccessToken(access);
    setRefreshToken(refresh);
    apiCalls.setAuthHeader(access);
    console.log('detect change in auth.')
}

console.log(store.getState());
store.subscribe(() => {
    updateAuth(store.getState());
});

export default store;

authSlice.js

import {createSlice} from "@reduxjs/toolkit";
import * as apiCalls from '../api/apiCalls'

const authSlice = createSlice({
    name: 'auth',
    initialState: null,
    reducers: {
        assignToken(state, action) {
            return {
                loggedIn: true,
                access: action.payload.access,
                refresh: action.payload.refresh,
            };
        },
        logOutUser(state, action) {
            return {
                loggedIn: false,
                access: '',
                refresh: '',
            };
        },
    }
});

export const {
    assignToken,
    logOutUser,
} = authSlice.actions

export default authSlice.reducer;

export function handleLogin(confidential) {
    return async function handleLoginThunk(dispatch, getState) {
        return apiCalls.getToken(confidential).then((response) => {
                console.log('log in: ', response);
                dispatch(assignToken({...response.data}));
            }
        ).catch((err) => {
            return Promise.reject(err.message);
        });
    }
};

export function handleRefresh(refresh) {
    return async function handleRefreshThunk(dispatch, getState) {
        apiCalls.refresh({'refresh': refresh}).then((response) => {
                console.log('refresh token:', ...response);
                dispatch(assignToken(...response.data));
            }
        ).catch((err) => {
        });
    }
};

export function handleLogOut() {
    return async function handleLogOutThunk(dispatch, getState) {
        const refreshToken = getState().auth.refresh;
        console.log('logout: ', refreshToken);
        dispatch(logOutUser());
        apiCalls.logout({refresh_token: refreshToken})
            .then((response) => {
            }).catch(error => {
        });
    }
};

LoginPage.js

import * as React from 'react';
import Avatar from '@mui/material/Avatar';
import Button from '@mui/material/Button';
import CssBaseline from '@mui/material/CssBaseline';
import TextField from '@mui/material/TextField';
import Link from '@mui/material/Link';
import Paper from '@mui/material/Paper';
import Box from '@mui/material/Box';
import Grid from '@mui/material/Grid';
import LockOutlinedIcon from '@mui/icons-material/LockOutlined';
import Typography from '@mui/material/Typography';
import {useDispatch, useSelector} from "react-redux"
import {createTheme, ThemeProvider} from '@mui/material/styles';
import {useEffect, useState} from "react";
import * as apiCalls from "../api/apiCalls";
import {clearErrorMessage, handleLogin, handleLogOut, handleRefresh, logInUser} from "../redux/authSlice";
import Copyright from "../components/Copyright";
import axios from "axios";
import {useNavigate} from "react-router-dom";
import {Alert} from "@mui/material";
import {getRefreshToken} from "../utils/token";

const theme = createTheme();


export default function SignInSide() {
    const navigate = useNavigate();
    const [apiCallPending, setApiCallPending] = useState(false);
    const [apiError, setApiError] = useState('');
    const [role, setRole] = useState('Staff');
    const [username, setUsername] = useState('');
    const [password, setPassword] = useState('');
    const loggedIn = useSelector(state => state.loggedIn);
    const errMsg = useSelector(state => state.message);
    const dispatch = useDispatch();
    const nextRole = () => {
        return role === 'Staff' ? 'Patient' : 'Staff';
    };
    // useEffect(() => {
    //     if (loggedIn)
    //         navigate('/dashboard', {state: {from: '/login'}});
    // });
    const onClickChangeRole = () => {
        setRole(nextRole());
    }
    const onClickLogIn = (event) => {
        event.preventDefault();
        setApiCallPending(true);
        const data = {username: username, password: password};
        dispatch(handleLogin(data)).then(response => {
            console.log('Log In Successfully: ', response);
            navigate('/dashboard', {state: {from: '/login'}});
        }).catch(err => {
        });
    };




    return (
        <ThemeProvider theme={theme}>
            <Grid container component="main" sx={{height: '100vh'}}>
                <CssBaseline/>
                <Grid
                    item
                    xs={false}
                    sm={4}
                    md={7}
                    sx={{
                        backgroundImage: 'url(https://images.unsplash.com/photo-1594824476967-48c8b964273f?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxzZWFyY2h8MjF8fGRvY3RvcnxlbnwwfHwwfHw%3D&auto=format&fit=crop&w=400&q=60)',
                        backgroundRepeat: 'no-repeat',
                        backgroundColor: (t) =>
                            t.palette.mode === 'light' ? t.palette.grey[50] : t.palette.grey[900],
                        backgroundSize: 'cover',
                        backgroundPosition: 'center',
                    }}
                />
                <Grid item xs={12} sm={8} md={5} component={Paper} elevation={6} square>
                    <Box
                        sx={{
                            my: 8,
                            mx: 4,
                            display: 'flex',
                            flexDirection: 'column',
                            alignItems: 'center',
                        }}
                    >
                        <Avatar sx={{m: 1, bgcolor: 'secondary.main'}}>
                            <LockOutlinedIcon/>
                        </Avatar>
                        <Typography component="h1" variant="h5">
                            {role} Sign in
                        </Typography>
                        <Box component="form" noValidate sx={{mt: 1}}>
                            <TextField
                                margin="normal"
                                required
                                fullWidth
                                id="username"
                                label="Username"
                                name="username"
                                value={username}
                                autoComplete="username"
                                autoFocus
                                onChange={(event) => {
                                    setUsername(event.target.value)
                                }}
                            />
                            {role === 'Staff' && <TextField
                                margin="normal"
                                required
                                fullWidth
                                name="password"
                                label="Password"
                                type="password"
                                id="password"
                                value={password}
                                onChange={(event) => {
                                    setPassword(event.target.value)
                                }}
                                autoComplete="current-password"
                            />}
                            {/*<FormControlLabel*/}
                            {/*    control={<Checkbox value="remember" color="primary"/>}*/}
                            {/*    label="Remember me"*/}
                            {/*/>*/}

                            {apiError !== '' && <Alert severity="error">{apiError}</Alert>}
                            <Button
                                type="submit"
                                fullWidth
                                variant="contained"
                                onClick={onClickLogIn}
                                sx={{mt: 3, mb: 2}}
                            >
                                Sign In
                            </Button>
                            <Button
                                onClick={onClickChangeRole}
                                fullWidth
                                variant="contained"
                                sx={{mb: 2}}
                            >
                                {`${nextRole()} Log In`}
                            </Button>
                            <Grid container>
                                <Grid item xs>
                                    <Link href="#" variant="body2">
                                        Forgot password?
                                    </Link>
                                </Grid>
                                <Grid item>
                                    <Link href="#" variant="body2">
                                        {"Don't have an account? Sign Up"}
                                    </Link>
                                </Grid>
                            </Grid>
                            <Copyright sx={{mt: 5}}/>
                        </Box>
                    </Box>
                </Grid>
            </Grid>
        </ThemeProvider>
    );
}

问题分析与解决方案

核心问题1:预加载状态的异步修改

在store.js中,创建store后仍在异步回调中直接修改preloadedState对象:

preloadedState.auth.access = getAccessToken();
preloadedState.auth.refresh = getRefreshToken();
preloadedState.auth.loggedIn = true;

Redux store初始化后,preloadedState已被作为初始状态引用,直接修改该对象会触发状态突变检测。

修复方案:
通过dispatch action更新状态,而非直接修改原始对象:

if (refreshToken !== '')    {
    apiCalls.refresh({'refresh': refreshToken}).then(resp => {
        setAccessToken(resp.data.access);
        setRefreshToken(resp.data.refresh);
        apiCalls.setAuthHeader(resp.data.access);
        // 用dispatch更新store状态
        store.dispatch(assignToken(resp.data));
        console.log('detect previous logged in: ', store.getState());
    }).catch(err => {
        console.log('no auth / expired token in localstorage');
    });
}

核心问题2:handleRefresh中的错误解构

在authSlice.js的handleRefresh函数中,错误地使用扩展运算符解构对象:

dispatch(assignToken(...response.data));

response.data是包含access和refresh的对象,扩展运算符会将对象键作为参数传递,导致assignToken接收错误的payload。

修复方案:
直接传递response.data对象:

export function handleRefresh(refresh) {
    return async function handleRefreshThunk(dispatch, getState) {
        apiCalls.refresh({'refresh': refresh}).then((response) => {
                console.log('refresh token:', response);
                dispatch(assignToken(response.data));
            }
        ).catch((err) => {
        });
    }
};

核心问题3:LoginPage中的useSelector路径错误

在LoginPage.js中,错误地从state根节点读取loggedIn:

const loggedIn = useSelector(state => state.loggedIn);

loggedIn实际位于state.auth下,此写法会导致值为undefined。

修复方案:
修正状态读取路径:

const loggedIn = useSelector(state => state.auth?.loggedIn || false);

其他优化建议

  1. 替换store.subscribe:使用Redux Toolkit的createListenerMiddleware处理localStorage同步逻辑,避免直接依赖subscribe。
  2. 完善错误处理:为空的catch块添加日志或用户提示,便于调试问题。

内容的提问来源于stack exchange,提问作者zening.chen

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.28 21:37:01