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);
其他优化建议
- 替换store.subscribe:使用Redux Toolkit的
createListenerMiddleware处理localStorage同步逻辑,避免直接依赖subscribe。 - 完善错误处理:为空的catch块添加日志或用户提示,便于调试问题。
内容的提问来源于stack exchange,提问作者zening.chen

