Skip to content
Closed
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
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
}, [options])
Comment on lines +44 to +46
Copy link

Choose a reason for hiding this comment

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

Stale Options Reference

Options reference updated after render cycle creates timing window where getSnapshot uses stale options. This race condition can cause inconsistent query state during rapid option changes, leading to UI displaying outdated data.

  // Update ref synchronously to avoid stale options in getSnapshot
  optionsRef.current = options

  React.useEffect(() => {
    // Effect kept for dependency tracking
  }, [options])
Commitable Suggestion
Suggested change
React.useEffect(() => {
optionsRef.current = options
}, [options])
// Update ref synchronously to avoid stale options in getSnapshot
optionsRef.current = options
React.useEffect(() => {
// Effect kept for dependency tracking
}, [options])
Standards
  • ISO-IEC-25010-Reliability
  • SRE-State-Consistency
  • DbC-Temporal-Safety

Comment on lines +44 to +46
Copy link

Choose a reason for hiding this comment

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

Stale Data Race

Race condition between useEffect updating optionsRef and useSyncExternalStore's getSnapshot function. During render cycles, getSnapshot may access stale options before useEffect runs, potentially exposing incorrect query state data to components.

Standards
  • CWE-362
  • CWE-367

Comment on lines +44 to +46
Copy link

Choose a reason for hiding this comment

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

Missing Effect Dependencies

useEffect lacks dependency array causing it to run on every render instead of only when options change. This violates React hooks correctness rules and creates unnecessary ref updates, potentially causing performance issues and unpredictable synchronization behavior.

Standards
  • Algorithm-Correctness-Effect-Dependencies
  • Business-Rule-React-Hooks-Correctness


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,
)!
}
Loading