Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 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
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. 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
20 changes: 19 additions & 1 deletion 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
) {
this.#executeFetch()
} else {
this.updateResult()
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 @@ -434,6 +434,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
121 changes: 76 additions & 45 deletions packages/react-query/src/HydrationBoundary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as React from 'react'

import { hydrate } from '@tanstack/query-core'
import { useQueryClient } from './QueryClientProvider'
import { IsHydratingProvider } from './IsHydratingProvider'
import type {
DehydratedState,
HydrateOptions,
Expand Down Expand Up @@ -50,62 +51,92 @@ 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
}
// Create a mutable ref object to hold pending query hashes
// This allows us to add/remove hashes without triggering re-renders
const hydratingQueriesRef = React.useRef<Set<string>>(new Set())

const hydrationQueue = React.useMemo(() => {
if (state) {
if (typeof state !== 'object') {
return undefined
}

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)

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 (!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
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) {
// Add pending hashes to the mutable ref
/* eslint-disable react-hooks/refs */
for (const dehydratedQuery of existingQueries) {
hydratingQueriesRef.current.add(dehydratedQuery.queryHash)
}
/* eslint-enable react-hooks/refs */
return existingQueries
Comment on lines +94 to +108
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash

# Find ESLint configuration files
echo "=== Checking for ESLint configuration files ==="
fd -t f -e js -e json -e yml -e yaml "eslint" . | head -20

# Check for .eslintrc variants
ls -la . | grep eslint || echo "No ESLint files in root"

# Check package.json for ESLint config
if [ -f package.json ]; then
  echo "=== Checking package.json for eslintConfig ==="
  jq '.eslintConfig' package.json 2>/dev/null || echo "No eslintConfig in root package.json"
fi

Repository: TanStack/query

Length of output: 1194


🏁 Script executed:

#!/bin/bash

# Look for ESLint configuration in packages directory
echo "=== Checking packages for ESLint config ==="
find packages -maxdepth 2 -name ".eslintrc*" -o -name "package.json" | while read file; do
  if [[ "$file" == *"package.json"* ]]; then
    if grep -q "eslintConfig" "$file" 2>/dev/null; then
      echo "Found eslintConfig in: $file"
    fi
  else
    echo "Found ESLint file: $file"
  fi
done

# Find the actual HydrationBoundary.tsx file
echo -e "\n=== Looking for HydrationBoundary.tsx ==="
find . -name "HydrationBoundary.tsx" -type f

Repository: TanStack/query

Length of output: 193


🏁 Script executed:

#!/bin/bash

# Search for any usage of "react-hooks/refs" in the codebase
echo "=== Searching for react-hooks/refs rule usage ==="
rg "react-hooks/refs" . -n

Repository: TanStack/query

Length of output: 687


🏁 Script executed:

#!/bin/bash

# Check the actual HydrationBoundary.tsx file
if [ -f "packages/react-query/src/HydrationBoundary.tsx" ]; then
  echo "=== Lines 94-108 of HydrationBoundary.tsx ==="
  sed -n '94,108p' packages/react-query/src/HydrationBoundary.tsx
  echo ""
  echo "=== Lines 136-141 of HydrationBoundary.tsx ==="
  sed -n '136,141p' packages/react-query/src/HydrationBoundary.tsx
fi

Repository: TanStack/query

Length of output: 1004


🏁 Script executed:

#!/bin/bash

# Check the eslint-plugin-react-hooks rules
echo "=== Checking for eslint-plugin-react-hooks documentation ==="
# First, let's see if it's installed and what version
if [ -d "node_modules/eslint-plugin-react-hooks" ]; then
  echo "eslint-plugin-react-hooks is installed"
  # Check available rules
  if [ -f "node_modules/eslint-plugin-react-hooks/package.json" ]; then
    echo "Version:"
    jq '.version' node_modules/eslint-plugin-react-hooks/package.json
  fi
else
  echo "eslint-plugin-react-hooks not found in node_modules"
fi

# Search for documentation or rule definitions
rg "rules:" node_modules/eslint-plugin-react-hooks --type json --type js | head -20 2>/dev/null || echo "Could not find rules definition"

Repository: TanStack/query

Length of output: 1355


🌐 Web query:

eslint-plugin-react-hooks rules documentation

πŸ’‘ Result:

The official documentation for eslint-plugin-react-hooks and its rules is on the React site. Key pages:

  • Plugin overview (recommended rules list and plugin notes). [1]
  • rules-of-hooks (react-hooks/rules-of-hooks): enforces the two Rules of Hooks (only call Hooks at top level; only call Hooks from React functions). [2]
  • exhaustive-deps (react-hooks/exhaustive-deps): validates dependency arrays for useEffect/useMemo/useCallback and config options for custom hooks. [3]

If you want, I can fetch and list every rule and their options/examples from the plugin docs.


🏁 Script executed:

#!/bin/bash

# Get the actual list of rules from eslint-plugin-react-hooks
echo "=== Extracting rules from eslint-plugin-react-hooks ==="
if [ -f "node_modules/eslint-plugin-react-hooks/package.json" ]; then
  jq '.main' node_modules/eslint-plugin-react-hooks/package.json
  # Check the main export
  MAIN_FILE=$(jq -r '.main' node_modules/eslint-plugin-react-hooks/package.json)
  if [ -f "node_modules/eslint-plugin-react-hooks/$MAIN_FILE" ]; then
    echo "=== Checking rules in main export ==="
    grep -A 50 "rules:" "node_modules/eslint-plugin-react-hooks/$MAIN_FILE" | head -70
  fi
fi

Repository: TanStack/query

Length of output: 164


🏁 Script executed:

#!/bin/bash

# Check the main eslint.config.js to see react-hooks configuration
echo "=== Checking root eslint.config.js ==="
cat eslint.config.js

Repository: TanStack/query

Length of output: 2186


🏁 Script executed:

#!/bin/bash

# Check if react-hooks/refs rule causes lint errors
# Look for how ESLint is configured for react-query package
echo "=== Checking react-query eslint.config.js ==="
cat packages/react-query/eslint.config.js

Repository: TanStack/query

Length of output: 936


🏁 Script executed:

#!/bin/bash

# Let's look at the actual rule definitions in the plugin
python3 << 'EOF'
import re
import json

# Read the eslint-plugin-react-hooks main file
with open('node_modules/eslint-plugin-react-hooks/cjs/eslint-plugin-react-hooks.production.js', 'r') as f:
    content = f.read()

# Search for rule names
rule_pattern = r"'(rules-of-hooks|exhaustive-deps|refs)'|'react-hooks/([\w-]+)'"
matches = re.findall(rule_pattern, content)

print("Rules found in eslint-plugin-react-hooks:")
# Also look for the object keys in rules object
rules_section = re.search(r'rules\s*:\s*{([^}]+)}', content)
if rules_section:
    rules_content = rules_section.group(1)
    # Find quoted strings that look like rule names
    rule_names = re.findall(r"'([^']+)':", rules_content)
    unique_rules = sorted(set(rule_names))
    for rule in unique_rules:
        print(f"  - {rule}")
EOF

Repository: TanStack/query

Length of output: 144


Remove invalid ESLint disable directives for non-existent react-hooks/refs rule

The react-hooks/refs rule does not exist in eslint-plugin-react-hooks. Only react-hooks/rules-of-hooks and react-hooks/exhaustive-deps are official rules. These disable directives are invalid and should be removed:

  • Line 97: // eslint-disable-next-line react-hooks/refs
  • Lines 103–107: /* eslint-disable react-hooks/refs */ / /* eslint-enable react-hooks/refs */
  • Lines 136–141: /* eslint-disable react-hooks/refs */ / /* eslint-enable react-hooks/refs */

Also in packages/react-query/src/useBaseQuery.ts:60

🧰 Tools
πŸͺ› ESLint

[error] 97-97: Definition for rule 'react-hooks/refs' was not found.

(react-hooks/refs)


[error] 103-103: Definition for rule 'react-hooks/refs' was not found.

(react-hooks/refs)


[error] 107-107: Definition for rule 'react-hooks/refs' was not found.

(react-hooks/refs)

πŸ€– Prompt for AI Agents
In `@packages/react-query/src/HydrationBoundary.tsx` around lines 94 - 108, Remove
the invalid ESLint disable directives referencing the non-existent rule
"react-hooks/refs" in HydrationBoundary.tsx and useBaseQuery.ts: delete the
inline comment before the hydrate call and the block comments around the loop
that adds to hydratingQueriesRef.current (the comments around the for...of loop
that references dehydratedQuery.queryHash) and any identical directives in
useBaseQuery (around the related refs usage); leave the hydrate(...) call and
the hydratingQueriesRef.current.add(...) logic unchanged, ensuring no other
eslint-disable comments are added.

}
return undefined
}, [client, state])
}
return undefined
}, [client, state])

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

const clearPendingQueries = () => {
if (hydrationQueue) {
for (const dehydratedQuery of hydrationQueue) {
hydratingQueriesRef.current.delete(dehydratedQuery.queryHash)
}
}
}

// 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
// Provide the mutable Set to children
// Children can check set.has(hash) without needing re-renders
// eslint-disable-next-line react-hooks/refs
const contextValue = React.useMemo(() => hydratingQueriesRef.current, [])

return (
<IsHydratingProvider value={contextValue}>{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<Set<string>>(new Set())

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