Summary
After upgrading from @reduxjs/toolkit v1.9.x to v2.x, calling api.util.resetApiState() no longer clears the data returned by a useQuery hook when the hook is currently in a skipped state (skip: true). In v1.9.7, the same operation correctly returned data: undefined after the reset.
The root cause I suspect is this line / guard:
queryArgs !== skipToken &&
added in queryStatePreSelector in buildHooks.ts in v2 , which prevents lastResult from being cleared when the hook's current queryArgs is skipToken.
Is this intended behavior or a bug?
Possibly related issues: #4891 , #3394
Versions
Upgraded from → to (exact versions from package-lock.json):
| Package |
Before |
After |
@reduxjs/toolkit |
1.9.7 |
2.11.2 |
react-redux |
8.1.3 |
9.2.0 |
react / react-dom |
18.3.1 |
19.2.4 |
redux |
4.2.1 |
5.0.1 |
reselect |
4.1.8 |
5.1.1 |
immer |
9.0.21 |
11.1.4 |
redux-thunk |
2.4.2 |
3.1.0 |
Reproduction
Hook definition
// useUserMenu.ts — a wrapper hook that skips the query when not authenticated
const useUserMenu = () => {
const { isAuthenticated } = useAppSelector(authSelector);
return useGetUserMenuQuery(undefined, {
skip: !isAuthenticated,
});
};
Logout handler
// LogoutButton.tsx
const performLogout = async () => {
try {
await dispatch(logout()).unwrap(); // sets isAuthenticated = false
dispatch(api.util.resetApiState()); // clear all RTK Query cache
} catch {
return;
}
router.push('/logout');
};
Consumer component
// NavBar.tsx — consumes the hook to render menu items
const NavBar = () => {
const { data: menuData } = useUserMenu();
const items = menuData?.menuItems ?? [];
return (
<nav>
{items.map((item) => (
<MenuItem key={item.id} {...item} />
))}
</nav>
);
};
Steps to reproduce
- User logs in →
isAuthenticated = true → useGetUserMenuQuery fetches and returns data: { menuItems: [...] }
- User clicks logout →
logout() dispatched (sets isAuthenticated = false), then api.util.resetApiState() dispatched
- After logout,
skip becomes true, so the hook receives queryArgs = skipToken
Expected (v1.9.7 behavior): data becomes undefined after resetApiState(), NavBar renders empty
Actual (v2.11.2 behavior): data retains the stale pre-logout menu items. Only a full page refresh clears it.
Debugging Done
- Redux DevTools / Inspector: Confirmed the API slice state IS correctly reset — the query cache entry is gone after
resetApiState().
- LocalStorage: Confirmed redux-persist storage is also cleared for the API slice.
- Chrome DevTools: Confirmed the issue is specifically in the hook's local
lastResult ref not being cleared, causing lastResult?.data to be returned as the data value.
Root Cause Analysis
In buildHooks.ts — queryStatePreSelector:
// v2.11.2
function queryStatePreSelector(currentState, lastResult, queryArgs) {
if (lastResult?.endpointName && currentState.isUninitialized) {
const { endpointName } = lastResult;
const endpointDefinition = endpointDefinitions[endpointName];
if (
queryArgs !== skipToken && // ← THIS GUARD
serializeQueryArgs({
queryArgs: lastResult.originalArgs,
endpointDefinition,
endpointName,
}) === serializeQueryArgs({ queryArgs, endpointDefinition, endpointName })
)
lastResult = undefined;
}
// ...
let data = currentState.isSuccess ? currentState.data : lastResult?.data; // stale data retained!
}
In v1.9.7, the same function does not have the queryArgs !== skipToken && guard:
// v1.9.7
if (
serializeQueryArgs({
queryArgs: lastResult.originalArgs,
endpointDefinition,
endpointName,
}) === serializeQueryArgs({ queryArgs, endpointDefinition, endpointName })
)
lastResult = undefined;
Why the serialization comparison passes
I verified in Chrome DevTools that:
serializeQueryArgs({
queryArgs: undefined,
endpointDefinition,
endpointName,
}) ===
serializeQueryArgs({
queryArgs: skipToken,
endpointDefinition,
endpointName,
});
// true
This is because skipToken is Symbol.for('RTKQ/skipToken') and JSON.stringify(Symbol(...)) returns undefined — the same result as JSON.stringify(undefined). So both produce "endpointName(undefined)".
The behavioral difference
| Version |
queryArgs at logout |
Condition |
lastResult cleared? |
| v1.9.7 |
skipToken |
serialize(originalArgs) === serialize(skipToken) → true |
✅ Yes |
| v2.11.2 |
skipToken |
skipToken !== skipToken → false, short-circuits |
❌ No |
In v2, because queryArgs !== skipToken evaluates to false (queryArgs IS skipToken when skip=true), the entire condition short-circuits, lastResult is never set to undefined, and the stale lastResult.data is returned.
Question
Is this intended behavior or a bug?
I understand the queryArgs !== skipToken guard was likely added intentionally — perhaps to avoid running serializeQueryArgs unnecessarily when the hook is skipped, or to prevent unintended resets. And I recognize that v1.9.7's behavior of resetting lastResult in the skipped state might itself have been unintended — it only worked because serializeQueryArgs happens to produce identical output for both undefined and skipToken (since JSON.stringify returns undefined for both).
However, from a user perspective, the v1.9.7 behavior (clearing data after resetApiState()) is the expected and useful one. When an app dispatches resetApiState() (e.g., during logout), all hooks should reflect the cleared state regardless of their current skip status.
If this is intended, what is the recommended pattern for ensuring hooks in a skipped state return undefined data after resetApiState()?
Current Workaround
const useUserMenu = () => {
const { isAuthenticated } = useAppSelector(authSelector);
const result = useGetUserMenuQuery(undefined, { skip: !isAuthenticated });
if (!isAuthenticated) {
return { ...result, data: undefined };
}
return result;
};
This works but requires manually overriding data in every hook that uses skip and expects resetApiState() to clear cached results.
Summary
After upgrading from
@reduxjs/toolkitv1.9.xtov2.x, callingapi.util.resetApiState()no longer clears thedatareturned by auseQueryhook when the hook is currently in a skipped state (skip: true). Inv1.9.7, the same operation correctly returneddata: undefinedafter the reset.The root cause I suspect is this line / guard:
added in
queryStatePreSelectorinbuildHooks.tsinv2, which preventslastResultfrom being cleared when the hook's currentqueryArgsisskipToken.Is this intended behavior or a bug?
Possibly related issues: #4891 , #3394
Versions
Upgraded from → to (exact versions from
package-lock.json):@reduxjs/toolkitreact-reduxreact/react-domreduxreselectimmerredux-thunkReproduction
Hook definition
Logout handler
Consumer component
Steps to reproduce
isAuthenticated = true→useGetUserMenuQueryfetches and returnsdata: { menuItems: [...] }logout()dispatched (setsisAuthenticated = false), thenapi.util.resetApiState()dispatchedskipbecomestrue, so the hook receivesqueryArgs = skipTokenExpected (v1.9.7 behavior):
databecomesundefinedafterresetApiState(), NavBar renders emptyActual (v2.11.2 behavior):
dataretains the stale pre-logout menu items. Only a full page refresh clears it.Debugging Done
resetApiState().lastResultref not being cleared, causinglastResult?.datato be returned as thedatavalue.Root Cause Analysis
In
buildHooks.ts—queryStatePreSelector:In v1.9.7, the same function does not have the
queryArgs !== skipToken &&guard:Why the serialization comparison passes
I verified in Chrome DevTools that:
This is because
skipTokenisSymbol.for('RTKQ/skipToken')andJSON.stringify(Symbol(...))returnsundefined— the same result asJSON.stringify(undefined). So both produce"endpointName(undefined)".The behavioral difference
queryArgsat logoutlastResultcleared?skipTokenserialize(originalArgs) === serialize(skipToken)→trueskipTokenskipToken !== skipToken→false, short-circuitsIn v2, because
queryArgs !== skipTokenevaluates tofalse(queryArgs IS skipToken when skip=true), the entire condition short-circuits,lastResultis never set toundefined, and the stalelastResult.datais returned.Question
Is this intended behavior or a bug?
I understand the
queryArgs !== skipTokenguard was likely added intentionally — perhaps to avoid runningserializeQueryArgsunnecessarily when the hook is skipped, or to prevent unintended resets. And I recognize that v1.9.7's behavior of resettinglastResultin the skipped state might itself have been unintended — it only worked becauseserializeQueryArgshappens to produce identical output for bothundefinedandskipToken(sinceJSON.stringifyreturnsundefinedfor both).However, from a user perspective, the v1.9.7 behavior (clearing data after
resetApiState()) is the expected and useful one. When an app dispatchesresetApiState()(e.g., during logout), all hooks should reflect the cleared state regardless of their current skip status.If this is intended, what is the recommended pattern for ensuring hooks in a skipped state return
undefineddata afterresetApiState()?Current Workaround
This works but requires manually overriding
datain every hook that usesskipand expectsresetApiState()to clear cached results.