Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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'`.

**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>> =
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 @@ -97,7 +98,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
) {
this.#executeFetch()
} else {
this.updateResult()
Expand Down
18 changes: 17 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 @@ -104,6 +112,14 @@ export const HydrationBoundary = ({
React.useEffect(() => {
if (hydrationQueue) {
hydrate(client, { queries: hydrationQueue }, optionsRef.current)
// Clear pending hydration flags after hydration completes
const queryCache = client.getQueryCache()
for (const dehydratedQuery of hydrationQueue) {
const query = queryCache.get(dehydratedQuery.queryHash)
if (query) {
pendingHydrationQueries.delete(query)
}
}
}
}, [client, hydrationQueue])

Expand Down
Loading
Loading