From def375a9180a455732f0c9ec450521b1abc9c242 Mon Sep 17 00:00:00 2001 From: Xandor Schiefer Date: Fri, 13 Mar 2026 16:39:34 +0200 Subject: [PATCH 1/2] feat(query-core/mutationObserver): result object property tracking --- packages/query-core/src/mutationObserver.ts | 78 +++++++++++++++++++-- packages/query-core/src/types.ts | 14 ++++ packages/react-query/src/useMutation.ts | 5 +- 3 files changed, 90 insertions(+), 7 deletions(-) diff --git a/packages/query-core/src/mutationObserver.ts b/packages/query-core/src/mutationObserver.ts index 21523963ceb..9209ab4a589 100644 --- a/packages/query-core/src/mutationObserver.ts +++ b/packages/query-core/src/mutationObserver.ts @@ -39,6 +39,7 @@ export class MutationObserver< > = undefined! #currentMutation?: Mutation #mutateOptions?: MutateOptions + #trackedProps = new Set() constructor( client: QueryClient, @@ -102,9 +103,47 @@ export class MutationObserver< onMutationUpdate( action: Action, ): void { + const prevResult = this.#currentResult as + | MutationObserverResult + | undefined + this.#updateResult() - this.#notify(action) + const shouldNotifyListeners = (): boolean => { + if (!prevResult) { + return true + } + + const { notifyOnChangeProps } = this.options + const notifyOnChangePropsValue = + typeof notifyOnChangeProps === 'function' + ? notifyOnChangeProps() + : notifyOnChangeProps + + if ( + notifyOnChangePropsValue === 'all' || + (!notifyOnChangePropsValue && !this.#trackedProps.size) + ) { + return true + } + + const includedProps = new Set( + notifyOnChangePropsValue ?? this.#trackedProps, + ) + + if (this.options.throwOnError) { + includedProps.add('error') + } + + return Object.keys(this.#currentResult).some((key) => { + const typedKey = key as keyof MutationObserverResult + const changed = this.#currentResult[typedKey] !== prevResult[typedKey] + + return changed && includedProps.has(typedKey) + }) + } + + this.#notify(action, { listeners: shouldNotifyListeners() }) } getCurrentResult(): MutationObserverResult< @@ -116,13 +155,35 @@ export class MutationObserver< return this.#currentResult } + trackResult( + nextResult: MutationObserverResult< + TData, + TError, + TVariables, + TOnMutateResult + >, + onPropTracked?: (key: keyof MutationObserverResult) => void, + ): MutationObserverResult { + return new Proxy(nextResult, { + get: (target, key) => { + this.trackProp(key as keyof MutationObserverResult) + onPropTracked?.(key as keyof MutationObserverResult) + return Reflect.get(target, key) + }, + }) + } + + trackProp(key: keyof MutationObserverResult) { + this.#trackedProps.add(key) + } + reset(): void { // reset needs to remove the observer from the mutation because there is no way to "get it back" // another mutate call will yield a new mutation! this.#currentMutation?.removeObserver(this) this.#currentMutation = undefined this.#updateResult() - this.#notify() + this.#notify(undefined, { listeners: true }) } mutate( @@ -158,7 +219,10 @@ export class MutationObserver< } as MutationObserverResult } - #notify(action?: Action): void { + #notify( + action?: Action, + notifyOptions?: { listeners?: boolean }, + ): void { notifyManager.batch(() => { // First trigger the mutate callbacks if (this.#mutateOptions && this.hasListeners()) { @@ -219,9 +283,11 @@ export class MutationObserver< } // Then trigger the listeners - this.listeners.forEach((listener) => { - listener(this.#currentResult) - }) + if (notifyOptions?.listeners) { + this.listeners.forEach((listener) => { + listener(this.#currentResult) + }) + } }) } } diff --git a/packages/query-core/src/types.ts b/packages/query-core/src/types.ts index 4f3f4caed20..0d40dac807e 100644 --- a/packages/query-core/src/types.ts +++ b/packages/query-core/src/types.ts @@ -222,6 +222,12 @@ export type NotifyOnChangeProps = | undefined | (() => Array | 'all' | undefined) +export type NotifyOnMutationChangeProps = + | Array + | 'all' + | undefined + | (() => Array | 'all' | undefined) + export interface QueryOptions< TQueryFnData = unknown, TError = DefaultError, @@ -1153,6 +1159,14 @@ export interface MutationObserverOptions< TOnMutateResult = unknown, > extends MutationOptions { throwOnError?: boolean | ((error: TError) => boolean) + /** + * If set, the component will only re-render if any of the listed properties change. + * When set to `['data', 'error']`, the component will only re-render when the `data` or `error` properties change. + * When set to `'all'`, the component will re-render whenever a mutation is updated. + * When set to a function, the function will be executed to compute the list of properties. + * By default, access to properties will be tracked, and the component will only re-render when one of the tracked properties change. + */ + notifyOnChangeProps?: NotifyOnMutationChangeProps } export interface MutateOptions< diff --git a/packages/react-query/src/useMutation.ts b/packages/react-query/src/useMutation.ts index 2c66eb8ba8d..7d1e67cac88 100644 --- a/packages/react-query/src/useMutation.ts +++ b/packages/react-query/src/useMutation.ts @@ -39,7 +39,7 @@ export function useMutation< observer.setOptions(options) }, [observer, options]) - const result = React.useSyncExternalStore( + const r = React.useSyncExternalStore( React.useCallback( (onStoreChange) => observer.subscribe(notifyManager.batchCalls(onStoreChange)), @@ -49,6 +49,9 @@ export function useMutation< () => observer.getCurrentResult(), ) + // Handle result property usage tracking + const result = !options.notifyOnChangeProps ? observer.trackResult(r) : r + const mutate = React.useCallback< UseMutateFunction >( From 0de67d41fc31674417df1800720920b29470b3f0 Mon Sep 17 00:00:00 2001 From: Xandor Schiefer Date: Fri, 13 Mar 2026 14:48:57 +0200 Subject: [PATCH 2/2] feat(query-core): observer result object referential stability --- .../react/guides/render-optimizations.md | 2 +- packages/query-core/src/mutationObserver.ts | 55 +++++++++++++--- packages/query-core/src/queriesObserver.ts | 26 ++++++-- packages/query-core/src/queryObserver.ts | 66 +++++++++++++------ packages/react-query/src/useMutation.ts | 4 +- 5 files changed, 115 insertions(+), 38 deletions(-) diff --git a/docs/framework/react/guides/render-optimizations.md b/docs/framework/react/guides/render-optimizations.md index 9edf7a467e7..86db4f83b10 100644 --- a/docs/framework/react/guides/render-optimizations.md +++ b/docs/framework/react/guides/render-optimizations.md @@ -13,7 +13,7 @@ React Query uses a technique called "structural sharing" to ensure that as many ### referential identity -The top level object returned from `useQuery`, `useInfiniteQuery`, `useMutation` and the Array returned from `useQueries` is **not referentially stable**. It will be a new reference on every render. However, the `data` properties returned from these hooks will be as stable as possible. +The top level object returned from `useQuery`, `useInfiniteQuery`, `useMutation` and the Array returned from `useQueries` is referentially stable unless React Query has triggered a re-render. ## tracked properties diff --git a/packages/query-core/src/mutationObserver.ts b/packages/query-core/src/mutationObserver.ts index 9209ab4a589..1f36bb14a02 100644 --- a/packages/query-core/src/mutationObserver.ts +++ b/packages/query-core/src/mutationObserver.ts @@ -40,6 +40,16 @@ export class MutationObserver< #currentMutation?: Mutation #mutateOptions?: MutateOptions #trackedProps = new Set() + #lastTrackedResult?: MutationObserverResult< + TData, + TError, + TVariables, + TOnMutateResult + > + #resultProxyCache = new WeakMap< + MutationObserverResult, // un-proxied result + MutationObserverResult // proxied result + >() constructor( client: QueryClient, @@ -164,13 +174,32 @@ export class MutationObserver< >, onPropTracked?: (key: keyof MutationObserverResult) => void, ): MutationObserverResult { - return new Proxy(nextResult, { - get: (target, key) => { - this.trackProp(key as keyof MutationObserverResult) - onPropTracked?.(key as keyof MutationObserverResult) - return Reflect.get(target, key) - }, - }) + let resultProxy = this.#resultProxyCache.get(nextResult) + + if (resultProxy) { + return resultProxy + } + + if (this.#lastTrackedResult) { + if (shallowEqualObjects(this.#lastTrackedResult, nextResult)) { + resultProxy = this.#resultProxyCache.get(this.#lastTrackedResult) + } + } + + if (!resultProxy) { + resultProxy = new Proxy(nextResult, { + get: (target, key) => { + this.trackProp(key as keyof MutationObserverResult) + onPropTracked?.(key as keyof MutationObserverResult) + return Reflect.get(target, key) + }, + }) + } + + this.#resultProxyCache.set(nextResult, resultProxy) + this.#lastTrackedResult = nextResult + + return resultProxy } trackProp(key: keyof MutationObserverResult) { @@ -204,11 +233,15 @@ export class MutationObserver< } #updateResult(): void { + const prevResult = this.#currentResult as + | MutationObserverResult + | undefined + const state = this.#currentMutation?.state ?? getDefaultState() - this.#currentResult = { + const nextResult = { ...state, isPending: state.status === 'pending', isSuccess: state.status === 'success', @@ -217,6 +250,12 @@ export class MutationObserver< mutate: this.mutate, reset: this.reset, } as MutationObserverResult + + if (shallowEqualObjects(nextResult, prevResult)) { + return + } + + this.#currentResult = nextResult } #notify( diff --git a/packages/query-core/src/queriesObserver.ts b/packages/query-core/src/queriesObserver.ts index 67dd088f9ae..bfc40a42e22 100644 --- a/packages/query-core/src/queriesObserver.ts +++ b/packages/query-core/src/queriesObserver.ts @@ -185,14 +185,22 @@ export class QueriesObserver< (match) => match.defaultedQueryOptions.queryHash, ) + const prevResult = this.#lastResult + + const getStableResult = (r: Array) => + prevResult && shallowEqualObjects(prevResult, r) ? prevResult : r + + const nextResult = getStableResult(result) + return [ - result, - (r?: Array) => { - return this.#combineResult(r ?? result, combine, queryHashes) - }, - () => { - return this.#trackResult(result, matches) - }, + nextResult, + (r?: Array) => + this.#combineResult( + (r && getStableResult(r)) ?? nextResult, + combine, + queryHashes, + ), + () => this.#trackResult(nextResult, matches), ] } @@ -200,6 +208,10 @@ export class QueriesObserver< result: Array, matches: Array, ) { + if (this.#lastResult && shallowEqualObjects(this.#lastResult, result)) { + return this.#lastResult + } + return matches.map((match, index) => { const observerResult = result[index]! return !match.defaultedQueryOptions.notifyOnChangeProps diff --git a/packages/query-core/src/queryObserver.ts b/packages/query-core/src/queryObserver.ts index 463407a0737..b270484ece8 100644 --- a/packages/query-core/src/queryObserver.ts +++ b/packages/query-core/src/queryObserver.ts @@ -67,6 +67,11 @@ export class QueryObserver< #refetchIntervalId?: ManagedTimerId #currentRefetchInterval?: number | false #trackedProps = new Set() + #lastTrackedResult?: QueryObserverResult + #resultProxyCache = new WeakMap< + QueryObserverResult, // un-proxied result + QueryObserverResult // proxied result + >() constructor( client: QueryClient, @@ -253,7 +258,7 @@ export class QueryObserver< this.#currentResultOptions = this.options this.#currentResultState = this.#currentQuery.state } - return result + return this.#currentResult } getCurrentResult(): QueryObserverResult { @@ -261,29 +266,48 @@ export class QueryObserver< } trackResult( - result: QueryObserverResult, + nextResult: QueryObserverResult, onPropTracked?: (key: keyof QueryObserverResult) => void, ): QueryObserverResult { - return new Proxy(result, { - get: (target, key) => { - this.trackProp(key as keyof QueryObserverResult) - onPropTracked?.(key as keyof QueryObserverResult) - if (key === 'promise') { - this.trackProp('data') - if ( - !this.options.experimental_prefetchInRender && - this.#currentThenable.status === 'pending' - ) { - this.#currentThenable.reject( - new Error( - 'experimental_prefetchInRender feature flag is not enabled', - ), - ) + let resultProxy = this.#resultProxyCache.get(nextResult) + + if (resultProxy) { + return resultProxy + } + + if (this.#lastTrackedResult) { + if (shallowEqualObjects(this.#lastTrackedResult, nextResult)) { + resultProxy = this.#resultProxyCache.get(this.#lastTrackedResult) + } + } + + if (!resultProxy) { + resultProxy = new Proxy(nextResult, { + get: (target, key) => { + this.trackProp(key as keyof QueryObserverResult) + onPropTracked?.(key as keyof QueryObserverResult) + if (key === 'promise') { + this.trackProp('data') + if ( + !this.options.experimental_prefetchInRender && + this.#currentThenable.status === 'pending' + ) { + this.#currentThenable.reject( + new Error( + 'experimental_prefetchInRender feature flag is not enabled', + ), + ) + } } - } - return Reflect.get(target, key) - }, - }) + return Reflect.get(target, key) + }, + }) + } + + this.#resultProxyCache.set(nextResult, resultProxy) + this.#lastTrackedResult = nextResult + + return resultProxy } trackProp(key: keyof QueryObserverResult) { diff --git a/packages/react-query/src/useMutation.ts b/packages/react-query/src/useMutation.ts index 7d1e67cac88..9ac96602c21 100644 --- a/packages/react-query/src/useMutation.ts +++ b/packages/react-query/src/useMutation.ts @@ -68,5 +68,7 @@ export function useMutation< throw result.error } - return { ...result, mutate, mutateAsync: result.mutate } + return React.useMemo(() => { + return Object.assign(result, { mutate, mutateAsync: result.mutate }) + }, [mutate, result]) }