Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
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
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
) {
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