Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

React Query - Missing fetchStatus changes for disabled queries with dynamic keys #8741

Open
istrel opened this issue Mar 3, 2025 · 3 comments · May be fixed by #8771
Open

React Query - Missing fetchStatus changes for disabled queries with dynamic keys #8741

istrel opened this issue Mar 3, 2025 · 3 comments · May be fixed by #8771

Comments

@istrel
Copy link

istrel commented Mar 3, 2025

Describe the bug

If you use useQuery with enabled: false and dynamic keys you can miss re-renders because of missing fetchStatus updates.

I.e. in this example (also posted on codesandbox) provided re-render will be triggered only when fetching the data is complete.

Your minimal, reproducible example

https://codesandbox.io/p/devbox/solitary-rgb-7szrj2

Steps to reproduce

Run the example (it's the copy of codesandbox) and press the button "Set ID and trigger user load":

import { createRoot } from "react-dom/client";
import {
  QueryClient,
  QueryClientProvider,
  useQuery,
} from "@tanstack/react-query";
import { useState } from "react";

const queryClient = new QueryClient();

const useUserInfoQuery = ({
  id,
  enabled,
}: {
  id: number | null;
  enabled: boolean;
}) => {
  return useQuery({
    queryKey: ["user", id],
    queryFn: () => {
      return new Promise((resolve) => {
        setTimeout(() => {
          resolve({ id, name: "John" });
        }, 2000);
      });
    },
    enabled: !!id && enabled,
  });
};

const App = () => {
  const [id, setId] = useState(null);

  const searchQuery = useUserInfoQuery({ id, enabled: false });

  return (
    <>
      <div>User fetching status is {searchQuery.fetchStatus}</div>
      <UserInfo id={id} />
      <button onClick={() => setId(42)}>Set ID and trigger user load</button>
    </>
  );
};

function UserInfo({ id }: { id: number | null }) {
  const searchQuery = useUserInfoQuery({ id, enabled: true });

  return <div>UserInfo data is {JSON.stringify(searchQuery.data)} </div>;
}

createRoot(document.getElementById("root")!).render(
  <QueryClientProvider client={queryClient}>
    <App />
  </QueryClientProvider>
);

Expected behavior

Expected behavior:

  • I see User fetching status is fetching until the user info is updated

Actual behavior:

  • I always see User fetching status is idle

How often does this bug happen?

Every time

Screenshots or Videos

No response

Platform

  • OS: macOS 14.5
  • Browser: Google Chrome 133.0.6943.142 (Official Build) (arm64)

Tanstack Query adapter

None

TanStack Query version

5.66.11

TypeScript version

5.7.3

Additional context

Here are some of the factors which matter for bug reproducibility:

  • setting constant queryKey instead of dynamic makes the bug disappear
  • changing composition of components affects the bug (e.g. moving <div>User fetching status is {searchQuery.fetchStatus}</div> to a separate component may make the bug disappear)
  • if you trigger re-render by something else (e.g. by adding useIsFetching) the bug is not reproduced
@TkDodo
Copy link
Collaborator

TkDodo commented Mar 5, 2025

This is quite interesting. The problem is that your first observer is permanently disabled, because it’s called with enabled: false and your condition is enabled: !!id && enabled.

Each observer tries to determine what the next state of the query will be, synchronously, to avoid having to wait for the fetch to actually happen. so fetchStatus will be set to fetching if we think that’s what the next state is going to be eventually. This happens here:

if (fetchOnMount || fetchOptionally) {
newState = {
...newState,
...fetchState(state.data, query.options),
}
}

Now when the first observer re-renders, it will see that it’s disabled and for all it knows, there won’t be a fetch coming, so it says fetchStatus: 'idle'. Then, the second observer comes around and that one will initiate the fetch. That's also why changing the order matters.

I don’t think there’s a good way to fix this, as this observer doesn’t have more info when rendering. There’s also no additional render happening when the fetch is actually triggered because we have already computed that state optimistically. That exception is here:

React.useEffect(() => {
// Do not notify on updates because of changes in the options because
// these changes should already be reflected in the optimistic result.
observer.setOptions(defaultedOptions, { listeners: false })
}, [defaultedOptions, observer])

now MAYBE we could remove that listeners: false exception and inform listeners anyways, I think this would fix the bug here. Since we have property tracking on per default, it mostly shouldn’t even harm render counts. If the result that’s computed after the fetch is the same as the optimistic one, there won’t be an additional render. If it’s not the same, we want an additional render. So I think this could be worth doing, even though users will likely see more renders if they have notifyOnChangeProps: 'all'. @Ephem what do you think?


not sure if this helps with your actual issue @istrel, but re-writing the sandbox to only disable the query when there is no id makes everything work, too:

https://stackblitz.com/edit/tanstack-query-nmvt14tm?file=src%2Findex.tsx&preset=node

that’s the usual approach to go about this, but I guess there is a reason why this one observer is permanently disabled?

@istrel
Copy link
Author

istrel commented Mar 5, 2025

but I guess there is a reason why this one observer is permanently disabled?

The reason why the observer is permanently disabled is that it's used in other place for showing the spinner. We have code similar to this:

const id = useAtomValue(idAtom);

const commonQuery = useCommonQuery({ id, enabled: false })
const queryForTab1 = useTab1Query({ id, enabled: false })
const queryForTab2 = useTab2Query({ id, enabled: false });

const isAnythingLoading = [
    commonQuery,
    queryForTab1,
    queryForTab2,
].some(q => q.isFetching);

return <RouterLayoutComponent>
    {loading ? <Spinner /> : null} 

    <Outlet />
</RouterLayoutComponent>

If we enable query it would trigger the request for the other tab even if it's not shown. This is not what we want because the HTTP request made is pretty big.

I know that we have useIsFetching() and I considered it as a viable workaround for triggering re-renders. But adding extra useIsFetching() without using its results seems a bit weird.
Relying on useIsFetching() result instead of disabled queries looks OK but returns incorrect results - this way the spinner could be shown because of some unwanted query.

@Ephem
Copy link
Collaborator

Ephem commented Mar 6, 2025

Interesting indeed, thanks for reporting this!

@Ephem what do you think?

I think this sounds very reasonable! I can see why listeners: false was added as an optimization, but correctness is more important. 😄 I'm sure there are ways to work around this bug, but I do think it's a bug and that it should be fixed.

TkDodo added a commit that referenced this issue Mar 8, 2025
we have an optimized `shouldNotifyListeners` logic internally in the QueryObserver that will make sure we only notify listeners that are interested in a change because they are using a specific key (tracked query properties)

the optimization to force a skip of the notification when updating options because the optimistic result should already contain all the necessary output falls apart in the situation described in the test and in the linked issue #8741.

while the issue itself is a niche edge-case, the fix is to actually remove a bunch of code, so I'm all for it
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging a pull request may close this issue.

3 participants