Skip to content
Closed
Show file tree
Hide file tree
Changes from 9 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
4 changes: 2 additions & 2 deletions packages/react-query/src/__tests__/useIsFetching.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,8 @@ describe('useIsFetching', () => {
const key = queryKey()

function Page() {
const isFetching = useIsFetching({}, queryClient)

useQuery(
{
queryKey: key,
Expand All @@ -216,8 +218,6 @@ describe('useIsFetching', () => {
queryClient,
)

const isFetching = useIsFetching({}, queryClient)

return (
<div>
<div>isFetching: {isFetching}</div>
Expand Down
9 changes: 7 additions & 2 deletions packages/react-query/src/__tests__/useMutationState.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,12 +66,15 @@ describe('useIsMutating', () => {
const isMutatingArray: Array<number> = []
const queryClient = createQueryClient()

function IsMutating() {
function IsMutatingBase() {
const isMutating = useIsMutating({ mutationKey: ['mutation1'] })
isMutatingArray.push(isMutating)
return null
}

// Memo to avoid other `useMutation` hook causing a re-render
const IsMutating = React.memo(IsMutatingBase)

function Page() {
const { mutate: mutate1 } = useMutation({
mutationKey: ['mutation1'],
Expand Down Expand Up @@ -104,7 +107,7 @@ describe('useIsMutating', () => {
const isMutatingArray: Array<number> = []
const queryClient = createQueryClient()

function IsMutating() {
function IsMutatingBase() {
const isMutating = useIsMutating({
predicate: (mutation) =>
mutation.options.mutationKey?.[0] === 'mutation1',
Expand All @@ -113,6 +116,8 @@ describe('useIsMutating', () => {
return null
}

const IsMutating = React.memo(IsMutatingBase)

function Page() {
const { mutate: mutate1 } = useMutation({
mutationKey: ['mutation1'],
Expand Down
1 change: 1 addition & 0 deletions packages/react-query/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export {
} from './QueryErrorResetBoundary'
export { useIsFetching } from './useIsFetching'
export { useIsMutating, useMutationState } from './useMutationState'
export { useQueryState } from './useQueryState'
export { useMutation } from './useMutation'
export { useInfiniteQuery } from './useInfiniteQuery'
export { useIsRestoring, IsRestoringProvider } from './isRestoring'
21 changes: 5 additions & 16 deletions packages/react-query/src/useIsFetching.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,13 @@
'use client'
import * as React from 'react'
import { notifyManager } from '@tanstack/query-core'

import { useQueryClient } from './QueryClientProvider'
import { useQueryState } from './useQueryState'
import type { QueryClient, QueryFilters } from '@tanstack/query-core'

export function useIsFetching(
filters?: QueryFilters,
queryClient?: QueryClient,
): number {
const client = useQueryClient(queryClient)
const queryCache = client.getQueryCache()

return React.useSyncExternalStore(
React.useCallback(
(onStoreChange) =>
queryCache.subscribe(notifyManager.batchCalls(onStoreChange)),
[queryCache],
),
() => client.isFetching(filters),
() => client.isFetching(filters),
)
return useQueryState(
{ filters: { ...filters, fetchStatus: 'fetching' } },
queryClient,
).length
}
23 changes: 12 additions & 11 deletions packages/react-query/src/useMutationState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,19 +64,20 @@ export function useMutationState<TResult = MutationState>(
return React.useSyncExternalStore(
React.useCallback(
(onStoreChange) =>
mutationCache.subscribe(() => {
const nextResult = replaceEqualDeep(
result.current,
getResult(mutationCache, optionsRef.current),
)
if (result.current !== nextResult) {
result.current = nextResult
notifyManager.schedule(onStoreChange)
}
}),
mutationCache.subscribe(notifyManager.batchCalls(onStoreChange)),
[mutationCache],
),
() => result.current,
() => {
const nextResult = replaceEqualDeep(
result.current,
getResult(mutationCache, optionsRef.current),

Choose a reason for hiding this comment

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

high

Using optionsRef.current here can lead to stale data. The optionsRef is updated in a useEffect, which runs after the render pass where getSnapshot is executed. This means getSnapshot might be using stale options.

You should use the options prop directly from the hook's arguments, as getSnapshot is recreated on each render and will have access to the latest options via its closure. This would also allow removing optionsRef and the corresponding useEffect from the hook.

Suggested change
getResult(mutationCache, optionsRef.current),
getResult(mutationCache, options),

Comment on lines +70 to +73
Copy link

Choose a reason for hiding this comment

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

Mutation State Staleness

Similar stale options reference pattern in mutation state hook creates data consistency issues. The optionsRef update lag during render cycles can cause mutation state to reflect outdated filter criteria.

() => {
  // Ensure we're using latest options by updating ref synchronously
  optionsRef.current = options
  
  const nextResult = replaceEqualDeep(
    result.current,
    getResult(mutationCache, optionsRef.current),
Commitable Suggestion
Suggested change
() => {
const nextResult = replaceEqualDeep(
result.current,
getResult(mutationCache, optionsRef.current),
() => {
// Ensure we're using latest options by updating ref synchronously
optionsRef.current = options
const nextResult = replaceEqualDeep(
result.current,
getResult(mutationCache, optionsRef.current),
Standards
  • ISO-IEC-25010-Reliability
  • SRE-State-Consistency
  • DbC-Temporal-Safety

Comment on lines +70 to +73
Copy link

Choose a reason for hiding this comment

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

Mutation State Race

Same race condition pattern as useQueryState where optionsRef updates lag behind render cycles. Components may receive stale mutation state data during option changes, potentially affecting UI security decisions based on mutation status.

Standards
  • CWE-362
  • CWE-367

)
if (result.current !== nextResult) {
result.current = nextResult
}

return result.current
},
() => result.current,
)!
Comment on lines 64 to 82
Copy link

Choose a reason for hiding this comment

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

Code Duplication Violation

Violates organization guideline requiring code reuse and redundancy elimination. The useSyncExternalStore pattern with cache subscription and result comparison is duplicated between useMutationState and useQueryState. This creates maintenance overhead and potential inconsistencies in state synchronization logic.

    return React.useSyncExternalStore(
      React.useCallback(
        (onStoreChange) => createCacheSubscription(mutationCache, onStoreChange),
        [mutationCache],
      ),
      () => getStateResult(result, () => getResult(mutationCache, optionsRef.current)),
      () => result.current,
    )!
Commitable Suggestion
Suggested change
return React.useSyncExternalStore(
React.useCallback(
(onStoreChange) =>
mutationCache.subscribe(() => {
const nextResult = replaceEqualDeep(
result.current,
getResult(mutationCache, optionsRef.current),
)
if (result.current !== nextResult) {
result.current = nextResult
notifyManager.schedule(onStoreChange)
}
}),
mutationCache.subscribe(notifyManager.batchCalls(onStoreChange)),
[mutationCache],
),
() => result.current,
() => {
const nextResult = replaceEqualDeep(
result.current,
getResult(mutationCache, optionsRef.current),
)
if (result.current !== nextResult) {
result.current = nextResult
}
return result.current
},
() => result.current,
)!
return React.useSyncExternalStore(
React.useCallback(
(onStoreChange) => createCacheSubscription(mutationCache, onStoreChange),
[mutationCache],
),
() => getStateResult(result, () => getResult(mutationCache, optionsRef.current)),
() => result.current,
)!
Standards
  • Org-Guideline-Reuse code wherever possible and avoid redundant code by refactoring with utils and static method across application
  • ISO-IEC-25010-Performance-Efficiency-Resource-Utilization

}
67 changes: 67 additions & 0 deletions packages/react-query/src/useQueryState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
'use client'
import * as React from 'react'

import { notifyManager, replaceEqualDeep } from '@tanstack/query-core'
import { useQueryClient } from './QueryClientProvider'
import type {
DefaultError,
Query,
QueryCache,
QueryClient,
QueryFilters,
QueryKey,
QueryState,
} from '@tanstack/query-core'

type QueryStateOptions<TResult = QueryState> = {
filters?: QueryFilters
select?: (query: Query<unknown, DefaultError, unknown, QueryKey>) => TResult
}

function getResult<TResult = QueryState>(
queryCache: QueryCache,
options: QueryStateOptions<TResult>,
): Array<TResult> {
return queryCache
.findAll(options.filters)
.map(
(query): TResult =>
(options.select ? options.select(query) : query.state) as TResult,
)
}

export function useQueryState<TResult = QueryState>(
options: QueryStateOptions<TResult> = {},
queryClient?: QueryClient,
): Array<TResult> {
const queryCache = useQueryClient(queryClient).getQueryCache()
const optionsRef = React.useRef(options)
const result = React.useRef<Array<TResult>>()
if (!result.current) {
result.current = getResult(queryCache, options)
}

React.useEffect(() => {
optionsRef.current = options
})
Copy link

Choose a reason for hiding this comment

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

State Synchronization Race

Options reference update lacks dependency array causing potential stale closure issues. Effect runs after every render potentially creating race conditions between option updates and state synchronization. Missing dependency array prevents React optimization and creates reliability gaps.

Standards
  • ISO-IEC-25010-Reliability-Fault-Tolerance
  • ISO-IEC-25010-Functional-Correctness-Appropriateness


return React.useSyncExternalStore(
React.useCallback(
(onStoreChange) =>
queryCache.subscribe(notifyManager.batchCalls(onStoreChange)),
Comment on lines +48 to +51
Copy link

Choose a reason for hiding this comment

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

Hook Logic Duplication

Identical useSyncExternalStore pattern duplicated between useQueryState and useMutationState creates maintenance overhead when state synchronization logic needs updates. Extract shared hook utility to eliminate code duplication and ensure consistent behavior across query management hooks.

Standards
  • Clean-Code-DRY
  • Refactoring-Extract-Function
  • SOLID-SRP

[queryCache],
),
Comment on lines +48 to +53
Copy link

Choose a reason for hiding this comment

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

Duplicated Subscription Pattern

Subscription pattern with notifyManager.batchCalls is duplicated across useQueryState and useMutationState. This creates redundant implementation that must be maintained in multiple locations. Refactor into shared subscription utility function.

  return React.useSyncExternalStore(
    React.useCallback(
      (onStoreChange) => createCacheSubscription(queryCache, onStoreChange),
      [queryCache],
    ),
Commitable Suggestion
Suggested change
return React.useSyncExternalStore(
React.useCallback(
(onStoreChange) =>
queryCache.subscribe(notifyManager.batchCalls(onStoreChange)),
[queryCache],
),
return React.useSyncExternalStore(
React.useCallback(
(onStoreChange) => createCacheSubscription(queryCache, onStoreChange),
[queryCache],
),
Standards
  • Org-Guideline-Reuse code wherever possible and avoid redundant code by refactoring with utils and static method across application
  • Clean-Code-DRY
  • Design-Pattern-Template-Method

() => {
const nextResult = replaceEqualDeep(
result.current,
getResult(queryCache, optionsRef.current),
)
if (result.current !== nextResult) {
result.current = nextResult
}

return result.current
},
Comment on lines +54 to +64
Copy link

Choose a reason for hiding this comment

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

Duplicated State Logic

Identical state management logic exists in both useQueryState and useMutationState hooks. This violates DRY principle creating maintenance overhead when state handling needs modification. Extract shared state management utility to eliminate code duplication.

    () => {
      return getStateResult(result, () => getResult(queryCache, optionsRef.current))
    },
Commitable Suggestion
Suggested change
() => {
const nextResult = replaceEqualDeep(
result.current,
getResult(queryCache, optionsRef.current),
)
if (result.current !== nextResult) {
result.current = nextResult
}
return result.current
},
() => {
return getStateResult(result, () => getResult(queryCache, optionsRef.current))
},
Standards
  • Org-Guideline-Reuse code wherever possible and avoid redundant code by refactoring with utils and static method across application
  • Clean-Code-DRY
  • Refactoring-Extract-Function

() => result.current,
)!
Comment on lines 37 to 66

Choose a reason for hiding this comment

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

high

The current implementation using optionsRef and useEffect can lead to returning stale data for one render cycle when the options prop changes. This is because useEffect runs after rendering, so optionsRef.current is updated too late for useSyncExternalStore's getSnapshot function, which runs during rendering.

A better approach is to remove optionsRef and useEffect, and use the options prop directly inside getSnapshot. Since getSnapshot is redefined on each render, it will correctly close over the latest options.

  const queryCache = useQueryClient(queryClient).getQueryCache()
  const result = React.useRef<Array<TResult>>()

  // Eagerly calculate result for first render and server render
  if (result.current === undefined) {
    result.current = getResult(queryCache, options)
  }

  return React.useSyncExternalStore(
    React.useCallback(
      (onStoreChange) =>
        queryCache.subscribe(notifyManager.batchCalls(onStoreChange)),
      [queryCache],
    ),
    () => {
      const nextResult = replaceEqualDeep(
        result.current,
        getResult(queryCache, options),
      )
      if (result.current !== nextResult) {
        result.current = nextResult
      }
      return result.current
    },
    () => result.current,
  )!

}
Loading