Skip to content

fix(react-query): create a new observer when the queryClient changes - #12257

Open
JacobLey wants to merge 1 commit into
TanStack:mainfrom
JacobLey:fix/react-query-client-change
Open

JacobLey wants to merge 1 commit into
TanStack:mainfrom
JacobLey:fix/react-query-client-change

Conversation

@JacobLey

@JacobLey JacobLey commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

🎯 Changes

Fixes #5458

useQuery and useQueries create their observer once, with the first queryClient they render with. If the client changes later (a different QueryClientProvider client, or a different queryClient argument), the hook takes options and cache lookups from the new client, but the result, the subscription and fetches still go through the old one.

In #5458 (comment) the suggestion was a setClient on the observer. I tried that first, but calling it during render changes the committed observer. If that render never commits (an interrupted transition, or a sibling that suspends or throws), the mounted observer is left on the new client, possibly subscribed to its query or fetching it. Calling it in an effect instead means the first render after the switch still shows the old client's data.

So this does what the Solid adapter does (#11920): when the client changes, the hook creates a new observer for it.

  • useBaseQuery and useQueries get their observer from a new internal useClientObserver(client, createObserver) hook instead of useState. It keeps { client, observer } in state. When the render's client differs from state.client, it creates a new observer for that client, uses it for that render, and stores it with React's "adjust state while rendering" pattern. React drops that update if the render is thrown away, so a render that doesn't commit never touches the committed observer.
  • useSyncExternalStore's subscribe callback and the setOptions effect already depend on observer, so after the commit React unsubscribes the old observer and subscribes the new one.
  • No query-core changes.

Tests:

  • For useQuery and useQueries: render with client A, then client B (through the provider and through the queryClient argument), with different data for the same key. The hook shows B's data on the first render with B, follows B.setQueryData, and ignores A.setQueryData. There's also a useQuery test where B has no data, so it fetches into B.
  • A useQuery test where a transition switches to client B and a sibling suspends, so the transition never commits. The mounted hook still reads A and still updates from A, B's query has no observer, and nothing is fetched. This test fails if the observer is switched during render.
  • Two existing tests only passed because the observer stayed on the old client:
    • HydrationBoundary "should hydrate queries to new cache if cache changes": after the switch it now shows the data hydrated into the new cache (stringCached), then the refetch result. The timer goes from 20ms to 21ms to let the 20ms queryFn finish.
    • PersistQueryClientProvider "should be able to persist into multiple clients": the provider only restores into the first client, and the test swaps the client right away. The hook used to show the restored data from the first client's cache, but fetched with the second client's queryFn. It now follows the second client, which was never restored, so it goes from pending straight to queryFn2. Restoring into a replaced client would be a separate change to the provider. The recorded states also have one more entry, because the render that switches observers runs twice.

Tradeoff: the new observer starts fresh, so previous data, placeholderData's previousData, and tracked props don't carry over from the old client.

This also changes what happens with an unstable client, such as new QueryClient() created during render. Previously the hook ignored every client after the first, so that mistake went unnoticed. Now:

  • If the client is created in the component that calls the hook (useQuery(options, new QueryClient())), each re-render that the hook triggers to store the new observer creates another client, so React throws "Too many re-renders" on the first render.
  • If it is created in a parent (<QueryClientProvider client={new QueryClient()}>), the hook settles, but each parent render gives it a new observer on an empty cache, so it refetches every time the parent renders.

Both are the misuse @tanstack/query/stable-query-client already flags.

preact-query has the same useBaseQuery/useQueries code and could get the same change in a follow-up.

✅ Checklist

  • I have followed the steps in the Contributing guide.
  • I have tested code changes locally with pnpm run test:pr, or these tests do not apply to this pull request. (I ran test:lib, test:types, test:oxlint and build for the touched packages, plus oxfmt. The test:build targets run npm pack, and I don't have npm installed locally.)
  • I have followed the AI contribution policy and fully understand the code in this pull request, including any code generated with AI assistance.

🚀 Release Impact

  • This change affects published code, and I have generated a changeset.
  • This change is docs/CI/dev-only (no release).

Summary by CodeRabbit

  • Bug Fixes
    • useQuery and useQueries now correctly switch to a new query client when the active provider or explicitly supplied client changes.
    • Observers detach from the previous client and respond only to updates from the active client.
    • Cached data remains associated with its original client, while the new client fetches data when needed.
  • Tests
    • Added coverage for client switching, cache preservation, hydration, persistence, and suspended renders.

@changeset-bot

changeset-bot Bot commented Oct 21, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 7462554

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 24 packages
Name Type
@tanstack/react-query Patch
@tanstack/react-query-devtools Patch
@tanstack/react-query-next-experimental Patch
@tanstack/react-query-persist-client Patch
@tanstack/angular-query-experimental Patch
@tanstack/eslint-plugin-query Patch
@tanstack/preact-query-devtools Patch
@tanstack/preact-query-persist-client Patch
@tanstack/preact-query Patch
@tanstack/query-async-storage-persister Patch
@tanstack/query-broadcast-client-experimental Patch
@tanstack/query-core Patch
@tanstack/query-devtools Patch
@tanstack/query-persist-client-core Patch
@tanstack/query-sync-storage-persister Patch
@tanstack/solid-query-devtools Patch
@tanstack/solid-query-persist-client Patch
@tanstack/solid-query Patch
@tanstack/vue-query Patch
@tanstack/lit-query Patch
@tanstack/svelte-query Patch
@tanstack/svelte-query-devtools Patch
@tanstack/vue-query-devtools Patch
@tanstack/svelte-query-persist-client Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@coderabbitai

coderabbitai Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository: TanStack/query/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: dc731c66-eb99-40ef-a1cb-ff172b7b4c0e
📥 Commits

Reviewing files that changed from the base of the PR and between 914eef7 and 7462554.

📒 Files selected for processing: 8
  • .changeset/react-query-client-change.md
  • packages/react-query-persist-client/src/__tests__/PersistQueryClientProvider.test.tsx
  • packages/react-query/src/__tests__/HydrationBoundary.test.tsx
  • packages/react-query/src/__tests__/useQueries.test.tsx
  • packages/react-query/src/__tests__/useQuery.test.tsx
  • packages/react-query/src/useBaseQuery.ts
  • packages/react-query/src/useClientObserver.ts
  • packages/react-query/src/useQueries.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 9 remain after this review.

📝 Walkthrough

useQuery and useQueries now create observers for the active QueryClient. Tests cover client changes through both QueryClientProvider and explicit queryClient arguments, including an uncommitted suspended render.

Changes: Query client observer switching

Layer / File(s) Summary
Create observers for the active client
packages/react-query/src/useClientObserver.ts, packages/react-query/src/useBaseQuery.ts, packages/react-query/src/useQueries.ts
useClientObserver creates a replacement observer when the client identity changes. useBaseQuery and useQueries use the helper instead of initializing their observers once.
Verify client switching behavior
packages/react-query/src/__tests__/useQuery.test.tsx, packages/react-query/src/__tests__/useQueries.test.tsx, packages/react-query-persist-client/src/__tests__/PersistQueryClientProvider.test.tsx, packages/react-query/src/__tests__/HydrationBoundary.test.tsx, .changeset/react-query-client-change.md
Tests cover provider and explicit-client changes, updates from the active client, fetches when the new client lacks cached data, and suspended renders. Persistence and hydration test expectations were also updated. The changeset documents the observer behavior.

Priority: ➖ Normal

Estimated code review effort: 3 (Moderate) | ~20 minutes

Change: Bug fix · Severity of issue fixed: Medium

Suggested reviewers: tkdodo

Merge Risk: ⚪ Minimal · up to 74625

The client-switching change is mergeable after normal checks; no actionable risk remains identified.

Pre-merge checks: Passed 4 · Failed 1

Failed Failed checks: 1 warning

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 27.27% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 11 functions across 7 files. (1 skipped: … Write docstrings for the functions missing them to satisfy the coverage threshold.
Passed Passed checks: 4 passed
Check name Status Explanation
Title check Passed The title clearly and concisely describes the primary change: creating a new observer when the queryClient changes.
Linked Issues check Passed Issue #5458 is closed. It provides historical context only. No active directly linked issue supplies coding requirements for this pull request.
Out of Scope Changes check Passed The changes stay within the stated React adapter scope. The new useClientObserver implementation, useQuery and useQueries integration, related tests, hydration and persistence test updates, and the ch…
Description check Passed The pull request description follows the required template. It explains the changes and motivation, completes the checklist, documents testing and its limitation, and confirms that a changeset was gen…
Full details: Out of Scope Changes check

Explanation

The changes stay within the stated React adapter scope. The new useClientObserver implementation, useQuery and useQueries integration, related tests, hydration and persistence test updates, and the changeset support the client-switch behavior. The pull request does not modify query-core or include the identified preact follow-up.

Full details: Description check

Explanation

The pull request description follows the required template. It explains the changes and motivation, completes the checklist, documents testing and its limitation, and confirms that a changeset was generated for published code.

Full details: Docstring Coverage

Explanation

Docstring coverage is 27.27% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 11 functions across 7 files. (1 skipped: 1 unsupported.)

Resolution

Write docstrings for the functions missing them to satisfy the coverage threshold.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

() =>
const observer = useClientObserver(
client,
(observerClient) =>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this argument isn't required (it literally is the client variable above, on every render), but I personally like it because it makes lack of a dependency array (which we don't need, we don't use any hooks that need one) obviously unnecessary (otherwise you get suspicious of the hook internals)

? observerState
: { client, observer: createObserver(client) }

if (nextObserverState !== observerState) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Called out in top-level description as well, but if somebody is mistakenly sending in a new client on every render, this would just keep rewriting state

That is pretty explicitly incorrect usage, but this might be the first place we would trigger a "too many renders" error?

IMHO acceptable (and this would just be a patch still, not something like a major)

This branch has not been deployed

No deployments
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 this pull request may close these issues.

Observer holding on to old queryClient when client changes in QueryClientProvider

1 participant