Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 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
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 @@ -538,7 +538,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. When using `HydrationBoundary`, React Query intelligently handles this: if the hydrated data is still fresh (within `staleTime`), it prevents unnecessary refetching during hydration. However, if the hydrated data is stale (e.g., from cached markup where the server fetch happened long ago), a refetch will be triggered. You can always force a refetch by setting `refetchOnMount` to `'always'`. For other approaches like `initialData`, you might want to use a higher `staleTime` to avoid double fetching.

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` intelligently prevents unnecessary refetching during hydration. Queries being hydrated will not trigger a refetch on mount if the data is still fresh (within `staleTime`). However, if the hydrated data is stale (e.g., from cached markup), a refetch will be triggered. You can always force a refetch by setting `refetchOnMount` to `'always'`.

**Options**

- `state: DehydratedState`
Expand Down
28 changes: 26 additions & 2 deletions packages/query-core/src/queryObserver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,25 @@ 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 via options._isHydrating
// If so, skip fetch unless:
// - refetchOnMount is explicitly 'always', or
// - the hydrated data is stale (e.g., cached markup scenario where
// server fetch happened long ago)
const resolvedRefetchOnMount =
typeof this.options.refetchOnMount === 'function'
? this.options.refetchOnMount(this.#currentQuery)
: this.options.refetchOnMount

const shouldSkipFetchForHydration =
this.options._isHydrating &&
resolvedRefetchOnMount !== 'always' &&
!isStale(this.#currentQuery, this.options)

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 Expand Up @@ -461,7 +479,13 @@ export class QueryObserver<
if (options._optimisticResults) {
const mounted = this.hasListeners()

const fetchOnMount = !mounted && shouldFetchOnMount(query, options)
// While a query is hydrating, the first render must match the server,
// which always rendered the dehydrated query as idle (not fetching),
// regardless of staleness. Suppress the optimistic fetch state so we
// don't introduce a hydration mismatch. The actual refetch (if the data
// is stale) is still triggered afterwards in `onSubscribe`.
const fetchOnMount =
!mounted && !options._isHydrating && shouldFetchOnMount(query, options)

const fetchOptionally =
mounted && shouldFetchOptionally(query, prevQuery, options, prevOptions)
Expand Down
6 changes: 6 additions & 0 deletions packages/query-core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,12 @@ export interface QueryObserverOptions<

_optimisticResults?: 'optimistic' | 'isRestoring'

/**
* Internal flag to indicate this query is pending hydration.
* When true, the observer will skip fetching on mount unless refetchOnMount is 'always'.
*/
_isHydrating?: boolean

/**
* Enable prefetching during rendering
*/
Expand Down
144 changes: 86 additions & 58 deletions packages/react-query/src/HydrationBoundary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,74 @@ import * as React from 'react'

import { hydrate } from '@tanstack/query-core'
import { useQueryClient } from './QueryClientProvider'
import { IsHydratingProvider } from './IsHydratingProvider'
import type {
DehydratedState,
HydrateOptions,
OmitKeyof,
QueryClient,
} from '@tanstack/query-core'

// Stable empty set shared as the context value once hydration has completed,
// so consumers never see stale pending flags and we avoid allocating per render.
const EMPTY_HYDRATING_QUERIES: ReadonlySet<string> = new Set()

// Splits the dehydrated queries into those that are new to the cache and those
// that already exist but carry newer data, and derives the pending set from the
// latter. Reads the cache but performs no mutation, so it is a pure function of
// its arguments and safe to call during render.
const classifyQueries = (
client: QueryClient,
state: DehydratedState | null | undefined,
) => {
if (!state || typeof state !== 'object') {
return {
newQueries: [] as DehydratedState['queries'],
existingQueries: [] as DehydratedState['queries'],
pendingQueries: EMPTY_HYDRATING_QUERIES,
}
}

const queryCache = client.getQueryCache()
// State is supplied from the outside and we might as well fail
// gracefully if it has the wrong shape, so while we type `queries`
// as required, we still provide a fallback.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
const queries = state.queries || []

const newQueries: DehydratedState['queries'] = []
const existingQueries: DehydratedState['queries'] = []
for (const dehydratedQuery of queries) {
const existingQuery = queryCache.get(dehydratedQuery.queryHash)

if (!existingQuery) {
newQueries.push(dehydratedQuery)
} else {
const hydrationIsNewer =
dehydratedQuery.state.dataUpdatedAt >
existingQuery.state.dataUpdatedAt ||
(dehydratedQuery.promise &&
existingQuery.state.status !== 'pending' &&
existingQuery.state.fetchStatus !== 'fetching' &&
dehydratedQuery.dehydratedAt !== undefined &&
dehydratedQuery.dehydratedAt > existingQuery.state.dataUpdatedAt)

if (hydrationIsNewer) {
existingQueries.push(dehydratedQuery)
}
}
}

// Reuse a stable empty set when nothing is pending so the context value keeps
// a stable reference and consumers don't recompute needlessly.
const pendingQueries =
existingQueries.length > 0
? new Set(existingQueries.map((query) => query.queryHash))
: EMPTY_HYDRATING_QUERIES

return { newQueries, existingQueries, pendingQueries }
}

export interface HydrationBoundaryProps {
state: DehydratedState | null | undefined
options?: OmitKeyof<HydrateOptions, 'defaultOptions'> & {
Expand All @@ -35,13 +96,9 @@ export const HydrationBoundary = ({
optionsRef.current = options
})

// This useMemo is for performance reasons only, everything inside it must
// be safe to run in every render and code here should be read as "in render".
//
// This code needs to happen during the render phase, because after initial
// SSR, hydration needs to happen _before_ children render. Also, if hydrating
// during a transition, we want to hydrate as much as is safe in render so
// we can prerender as much as possible.
// Side effects that must only run when `state` changes, not on every render:
// hydrate brand new queries immediately (in render, before children render, so
// they can prerender) and defer existing queries to the effect below.
//
// For any queries that already exist in the cache, we want to hold back on
// hydrating until _after_ the render phase. The reason for this is that during
Expand All @@ -50,62 +107,33 @@ export const HydrationBoundary = ({
// If the transition is aborted, we will have hydrated any _new_ queries, but
// we throw away the fresh data for any existing ones to avoid unexpectedly
// updating the UI.
const hydrationQueue: DehydratedState['queries'] | undefined =
React.useMemo(() => {
if (state) {
if (typeof state !== 'object') {
return
}

const queryCache = client.getQueryCache()
// State is supplied from the outside and we might as well fail
// gracefully if it has the wrong shape, so while we type `queries`
// as required, we still provide a fallback.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
const queries = state.queries || []

const newQueries: DehydratedState['queries'] = []
const existingQueries: DehydratedState['queries'] = []
for (const dehydratedQuery of queries) {
const existingQuery = queryCache.get(dehydratedQuery.queryHash)

if (!existingQuery) {
newQueries.push(dehydratedQuery)
} else {
const hydrationIsNewer =
dehydratedQuery.state.dataUpdatedAt >
existingQuery.state.dataUpdatedAt ||
(dehydratedQuery.promise &&
existingQuery.state.status !== 'pending' &&
existingQuery.state.fetchStatus !== 'fetching' &&
dehydratedQuery.dehydratedAt !== undefined &&
dehydratedQuery.dehydratedAt >
existingQuery.state.dataUpdatedAt)

if (hydrationIsNewer) {
existingQueries.push(dehydratedQuery)
}
}
}

if (newQueries.length > 0) {
// It's actually fine to call this with queries/state that already exists
// in the cache, or is older. hydrate() is idempotent for queries.
// eslint-disable-next-line react-hooks/refs
hydrate(client, { queries: newQueries }, optionsRef.current)
}
if (existingQueries.length > 0) {
return existingQueries
}
}
return undefined
}, [client, state])
const hydrationQueue = React.useMemo(() => {
const { newQueries, existingQueries } = classifyQueries(client, state)

if (newQueries.length > 0) {
// It's actually fine to call this with queries/state that already exists
// in the cache, or is older. hydrate() is idempotent for queries.
// eslint-disable-next-line react-hooks/refs
hydrate(client, { queries: newQueries }, optionsRef.current)
}

return existingQueries.length > 0 ? existingQueries : undefined
}, [client, state])

// The pending set drives `_isHydrating` for consumers, which is read per
// observer at mount. It must be recomputed on every render (not memoized on
// `state` identity) so that once the deferred hydration lands and the cache
// catches up, the query is no longer classified as newer and the flag clears
// for any observer mounting afterwards.
const { pendingQueries } = classifyQueries(client, state)

React.useEffect(() => {
if (hydrationQueue) {
hydrate(client, { queries: hydrationQueue }, optionsRef.current)
}
}, [client, hydrationQueue])

return children as React.ReactElement
return (
<IsHydratingProvider value={pendingQueries}>{children}</IsHydratingProvider>
)
}
7 changes: 7 additions & 0 deletions packages/react-query/src/IsHydratingProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
'use client'
import * as React from 'react'

const IsHydratingContext = React.createContext<ReadonlySet<string>>(new Set())

export const useIsHydrating = () => React.useContext(IsHydratingContext)
export const IsHydratingProvider = IsHydratingContext.Provider
Loading
Loading