Skip to content
Open
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
8626d42
fix(react-query/HydrationBoundary): prevent unnecessary refetch durin…
sukvvon Dec 14, 2025
25499fc
Merge branch 'main' into fix/hydration-boundary-double-fetch
sukvvon Dec 27, 2025
695fa28
chore(.changeset): change version bump from 'patch' to 'minor'
sukvvon Dec 27, 2025
2738c99
refactor(react-query/HydrationBoundary): add useEffect cleanup and ex…
sukvvon Dec 27, 2025
9267ccc
Merge branch 'main' into fix/hydration-boundary-double-fetch
sukvvon Dec 28, 2025
992bee3
Merge branch 'main' into fix/hydration-boundary-double-fetch
sukvvon Dec 28, 2025
b8f5e0a
Merge branch 'main' into fix/hydration-boundary-double-fetch
sukvvon Dec 28, 2025
9d64cf1
Merge branch 'main' into fix/hydration-boundary-double-fetch
sukvvon Dec 29, 2025
bcd214e
chore(.changeset): change version bump from 'minor' to 'patch'
sukvvon Dec 29, 2025
607f405
Merge branch 'main' into fix/hydration-boundary-double-fetch
sukvvon Dec 29, 2025
aa4c923
Merge branch 'main' into fix/hydration-boundary-double-fetch
sukvvon Dec 31, 2025
653df2c
Merge branch 'main' into fix/hydration-boundary-double-fetch
sukvvon Dec 31, 2025
08715e6
Merge branch 'main' into fix/hydration-boundary-double-fetch
sukvvon Jan 11, 2026
d3bf064
Merge branch 'main' into fix/hydration-boundary-double-fetch
sukvvon Jan 17, 2026
2a65e27
Merge branch 'main' into fix/hydration-boundary-double-fetch
sukvvon Jan 18, 2026
78ce3f5
Merge branch 'main' into fix/hydration-boundary-double-fetch
sukvvon Jan 19, 2026
058fee0
refactor(react-query): replace global 'pendingHydrationQueries' with …
sukvvon Jan 21, 2026
cdbc754
ci: apply automated fixes
autofix-ci[bot] Jan 21, 2026
f840a0a
fix(query-core): refetch stale data during hydration for cached marku…
sukvvon Jan 21, 2026
bb9cced
docs(react): update hydration docs to reflect stale data refetch beha…
sukvvon Jan 21, 2026
b63f6ec
refactor(react-query/IsHydratingProvider): simplify context definition
sukvvon Jan 21, 2026
c4ad620
refactor(react-query): simplify hydration context from '{ current: Se…
sukvvon Jan 21, 2026
888f65a
Merge branch 'main' into fix/hydration-boundary-double-fetch
sukvvon Jan 26, 2026
555e2b9
Merge branch 'main' into fix/hydration-boundary-double-fetch
sukvvon Jan 31, 2026
5fec087
ci: trigger CI
sukvvon Feb 4, 2026
8da1001
Merge branch 'main' into fix/hydration-boundary-double-fetch
sukvvon Feb 11, 2026
a8d3c99
Merge branch 'main' into fix/hydration-boundary-double-fetch
sukvvon Feb 13, 2026
2faa447
Merge branch 'main' into fix/hydration-boundary-double-fetch
sukvvon Feb 14, 2026
2362ae8
Merge branch 'main' into fix/hydration-boundary-double-fetch
sukvvon Feb 16, 2026
c37ad5e
Merge branch 'main' into fix/hydration-boundary-double-fetch
sukvvon Feb 25, 2026
95d9f86
Merge branch 'main' into fix/hydration-boundary-double-fetch
sukvvon Mar 2, 2026
e7e6882
Merge branch 'main' into fix/hydration-boundary-double-fetch
sukvvon Mar 18, 2026
dba4b76
Merge branch 'main' into fix/hydration-boundary-double-fetch
sukvvon Mar 25, 2026
e6c8ad8
Merge branch 'main' into fix/hydration-boundary-double-fetch
sukvvon Jul 6, 2026
e4c589f
fix(react-query/HydrationBoundary): derive pending hydration set in r…
sukvvon Jul 7, 2026
8ab4e34
fix(query-core/queryObserver): match the server on the first hydratin…
sukvvon Jul 7, 2026
8c1d38f
Merge branch 'main' into fix/hydration-boundary-double-fetch
sukvvon Jul 8, 2026
71806f7
Merge branch 'main' into fix/hydration-boundary-double-fetch
sukvvon Jul 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/fix-hydration-double-fetch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@tanstack/query-core': patch
'@tanstack/react-query': patch
---

fix(react-query/HydrationBoundary): prevent unnecessary refetch during hydration
2 changes: 1 addition & 1 deletion docs/framework/react/guides/ssr.md
Original file line number Diff line number Diff line change
Expand Up @@ -536,7 +536,7 @@ This is much better, but if we want to improve this further we can flatten this

A query is considered stale depending on when it was `dataUpdatedAt`. A caveat here is that the server needs to have the correct time for this to work properly, but UTC time is used, so timezones do not factor into this.

Because `staleTime` defaults to `0`, queries will be refetched in the background on page load by default. You might want to use a higher `staleTime` to avoid this double fetching, especially if you don't cache your markup.
Because `staleTime` defaults to `0`, queries will be refetched in the background on page load by default. However, when using `HydrationBoundary`, React Query automatically prevents this unnecessary refetching during hydration (unless `refetchOnMount` is explicitly set to `'always'`). For other approaches like `initialData`, you might want to use a higher `staleTime` to avoid this double fetching, especially if you don't cache your markup.

This refetching of stale queries is a perfect match when caching markup in a CDN! You can set the cache time of the page itself decently high to avoid having to re-render pages on the server, but configure the `staleTime` of the queries lower to make sure data is refetched in the background as soon as a user visits the page. Maybe you want to cache the pages for a week, but refetch the data automatically on page load if it's older than a day?

Expand Down
2 changes: 2 additions & 0 deletions docs/framework/react/reference/hydration.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,8 @@ function App() {

> Note: Only `queries` can be dehydrated with an `HydrationBoundary`.

> Note: `HydrationBoundary` automatically prevents unnecessary refetching during hydration. Queries being hydrated will not trigger a refetch on mount, unless `refetchOnMount` is explicitly set to `'always'`.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thank you for this PR! I've only had time to skim it so far and it will take some time before I can dig in properly, but I wanted to provide some quick feedback here.

We also need to account for the case where there is some time in between fetching on the server, and hydrating on the client. This commonly happens when the markup is cached, in which case the hydration can happen days after the fetch on the server. In this situation, we do want to render with the stale data initially (this is what the SSR pass did and we need to match it), but we also want to immediately refetch the data because it's stale.


**Options**

- `state: DehydratedState`
Expand Down
5 changes: 5 additions & 0 deletions packages/query-core/src/hydration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ import type { QueryClient } from './queryClient'
import type { Query, QueryState } from './query'
import type { Mutation, MutationState } from './mutation'

// WeakSet to track queries that are pending hydration
// Used to prevent double-fetching when HydrationBoundary defers hydration to useEffect
export const pendingHydrationQueries: WeakSet<Query<any, any, any, any>> =

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I don't think we can use a global singleton to keep track of this, what if there are several queryClients? In that case a query might just be about to hydrate into one of these, not all of them.

Annoying as that is, I think we need to use a context for this. Note that there is some prior art with isRestoring.

new WeakSet()

// TYPES
type TransformerFn = (data: any) => any
function defaultTransformerFn(data: any): any {
Expand Down
1 change: 1 addition & 0 deletions packages/query-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export {
defaultShouldDehydrateQuery,
dehydrate,
hydrate,
pendingHydrationQueries,
} from './hydration'
export { InfiniteQueryObserver } from './infiniteQueryObserver'
export { MutationCache } from './mutationCache'
Expand Down
20 changes: 19 additions & 1 deletion packages/query-core/src/queryObserver.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { focusManager } from './focusManager'
import { pendingHydrationQueries } from './hydration'
import { notifyManager } from './notifyManager'
import { fetchState } from './query'
import { Subscribable } from './subscribable'
Expand Down Expand Up @@ -96,7 +97,24 @@ export class QueryObserver<
if (this.listeners.size === 1) {
this.#currentQuery.addObserver(this)

if (shouldFetchOnMount(this.#currentQuery, this.options)) {
// Check if this query is pending hydration
// If so, skip fetch unless refetchOnMount is explicitly 'always'
const hasPendingHydration = pendingHydrationQueries.has(
this.#currentQuery,
)

const resolvedRefetchOnMount =
typeof this.options.refetchOnMount === 'function'
? this.options.refetchOnMount(this.#currentQuery)
: this.options.refetchOnMount

const shouldSkipFetchForHydration =
hasPendingHydration && resolvedRefetchOnMount !== 'always'

if (
shouldFetchOnMount(this.#currentQuery, this.options) &&
!shouldSkipFetchForHydration
) {
Comment on lines +114 to +117

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

There's also logic in createResult that needs to account for this. That's also used to determine what state the query will be in, the optimistic result that the query hooks see on first render, before this is called and the actual fetch (possibly) happens.

This gets into some other tricky territory (where we might already have other bugs tbh), which is that the first render needs to match the render on the server. If the data was not stale when server rendering, but is now stale, we need to:

  • Do the first render as if it's not stale to avoid hydration mismatches
  • Still trigger a refetch and update the query after the first render

this.#executeFetch()
} else {
this.updateResult()
Expand Down
28 changes: 27 additions & 1 deletion packages/react-query/src/HydrationBoundary.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use client'
import * as React from 'react'

import { hydrate } from '@tanstack/query-core'
import { hydrate, pendingHydrationQueries } from '@tanstack/query-core'
import { useQueryClient } from './QueryClientProvider'
import type {
DehydratedState,
Expand Down Expand Up @@ -95,6 +95,14 @@ export const HydrationBoundary = ({
hydrate(client, { queries: newQueries }, optionsRef.current)
}
if (existingQueries.length > 0) {
// Mark existing queries as pending hydration to prevent double-fetching
// The flag will be cleared in useEffect after hydration completes
for (const dehydratedQuery of existingQueries) {
const query = queryCache.get(dehydratedQuery.queryHash)
if (query) {
pendingHydrationQueries.add(query)
}
}
return existingQueries
}
}
Expand All @@ -105,6 +113,24 @@ export const HydrationBoundary = ({
if (hydrationQueue) {
hydrate(client, { queries: hydrationQueue }, optionsRef.current)
}

const clearPendingQueries = () => {
if (hydrationQueue) {
const queryCache = client.getQueryCache()
for (const dehydratedQuery of hydrationQueue) {
const query = queryCache.get(dehydratedQuery.queryHash)
if (query) {
pendingHydrationQueries.delete(query)
}
}
}
}

// Clear pending hydration flags after hydration completes
clearPendingQueries()

// Cleanup: also clear on unmount in case component unmounts before effect runs
return clearPendingQueries
}, [client, hydrationQueue])

return children as React.ReactElement
Expand Down
Loading
Loading