diff --git a/docs/rtk-query/usage/queries.mdx b/docs/rtk-query/usage/queries.mdx
index 0448cf667d..74a00ea1bb 100644
--- a/docs/rtk-query/usage/queries.mdx
+++ b/docs/rtk-query/usage/queries.mdx
@@ -123,7 +123,29 @@ The `queryArg` param will be passed through to the underlying `query` callback t
:::caution
-The `queryArg` param is handed to a `useEffect` dependency array internally. RTK Query tries to keep the argument stable by performing a `shallowEquals` diff on the value, however if you pass a deeper nested argument, you will need to keep the param stable yourself, e.g. with `useMemo`.
+The `queryArg` param is handed to a `useEffect` dependency array internally.
+RTK Query tries to keep the argument stable by performing a `shallowEquals` diff on the value,
+however if you pass a deeper nested argument, you will need to keep the param stable yourself,
+e.g. with `useMemo`.
+
+```diff
+ function FilterList({ kind, sortField = 'created_at', sortOrder = 'ASC' }) {
+- // The `sort` object would fail the internal shallow stability check and be new every render
+- const result = useMyQuery({
+- kind,
+- sort: { field: sortField, order: sortOrder },
+- })
+
++ // `useMemo` can be used to maintain stability for the entire argument object
++ const queryArg = useMemo(
++ () => ({ kind, sort: { field: sortField, order: sortOrder } }),
++ [kind, sortField, sortOrder]
++ )
++ const result = useMyQuery(queryArg)
+
+ return
...
+ }
+```
:::
diff --git a/packages/toolkit/src/query/react/buildHooks.ts b/packages/toolkit/src/query/react/buildHooks.ts
index d956bb1a91..3dfdfda5c0 100644
--- a/packages/toolkit/src/query/react/buildHooks.ts
+++ b/packages/toolkit/src/query/react/buildHooks.ts
@@ -46,6 +46,7 @@ import type {
} from '@reduxjs/toolkit/dist/query/core/module'
import type { ReactHooksModuleOptions } from './module'
import { useShallowStableValue } from './useShallowStableValue'
+import { useStabilityMonitor } from './useStabilityMonitor'
import type { UninitializedValue } from './constants'
import { UNINITIALIZED_VALUE } from './constants'
@@ -506,6 +507,10 @@ export function buildHooks({
>
const dispatch = useDispatch>()
const stableArg = useShallowStableValue(skip ? skipToken : arg)
+ useStabilityMonitor(stableArg, {
+ errMsg:
+ 'The Query argument has been detected as changing too many times within a short period. See https://redux-toolkit.js.org/rtk-query/usage/queries#query-hook-options for more information on how to stabilize the argument and fix this issue.',
+ })
const stableSubscriptionOptions = useShallowStableValue({
refetchOnReconnect,
refetchOnFocus,
diff --git a/packages/toolkit/src/query/react/useStabilityMonitor.ts b/packages/toolkit/src/query/react/useStabilityMonitor.ts
new file mode 100644
index 0000000000..9359732ff8
--- /dev/null
+++ b/packages/toolkit/src/query/react/useStabilityMonitor.ts
@@ -0,0 +1,42 @@
+import { useRef, useEffect } from 'react'
+
+/**
+ * Monitors the 'stability' of the provided value across renders.
+ * If the value changes more times than the threshold within the provided
+ * time delta, an error will be through.
+ *
+ * Defaults to throwing if changing 10 times in 10 consecutive renders within 1000ms
+ */
+export function useStabilityMonitor(
+ value: T,
+ {
+ delta = 1000,
+ threshold = 10,
+ errMsg = 'Value changed too many times.',
+ } = {}
+) {
+ const lastValue = useRef(value)
+ const consecutiveTimestamps = useRef([])
+
+ useEffect(() => {
+ // where a render occurs but value didn't change, consider the value to be 'stable'
+ // and clear recorded timestamps.
+ // i.e. only keep timestamps if the value changes every render
+ if (lastValue.current === value) consecutiveTimestamps.current = []
+ lastValue.current = value
+ })
+
+ useEffect(() => {
+ const now = Date.now()
+ consecutiveTimestamps.current.push(now)
+ consecutiveTimestamps.current = consecutiveTimestamps.current.filter((timestamp) => {
+ return timestamp > now - delta
+ })
+
+ if (consecutiveTimestamps.current.length >= threshold) {
+ const err = new Error(errMsg)
+ Error.captureStackTrace(err, useStabilityMonitor)
+ throw err
+ }
+ }, [value, delta, threshold, errMsg])
+}
diff --git a/packages/toolkit/src/query/tests/buildHooks.test.tsx b/packages/toolkit/src/query/tests/buildHooks.test.tsx
index 18ea3ee57c..2d8300e208 100644
--- a/packages/toolkit/src/query/tests/buildHooks.test.tsx
+++ b/packages/toolkit/src/query/tests/buildHooks.test.tsx
@@ -34,7 +34,7 @@ const api = createApi({
amount += 1
}
- if (arg?.body && 'forceError' in arg.body) {
+ if (arg?.body && 'forceError' in arg.body && arg.body.forceError) {
return {
error: {
status: 500,
@@ -65,7 +65,12 @@ const api = createApi({
query: (update) => ({ body: update }),
}),
getError: build.query({
- query: (query) => '/error',
+ query: (query) => ({
+ url: '/error',
+ body: {
+ forceError: true,
+ },
+ }),
}),
}),
})
@@ -2029,3 +2034,59 @@ describe('skip behaviour', () => {
expect(subscriptionCount('getUser(1)')).toBe(0)
})
})
+
+describe('query arg stability monitoring', () => {
+ type ErrorBoundaryProps = {
+ children: React.ReactNode
+ }
+ class ErrorBoundary extends React.Component<
+ ErrorBoundaryProps,
+ { hasError: boolean; errMsg: string }
+ > {
+ constructor(props: ErrorBoundaryProps) {
+ super(props)
+ this.state = { hasError: false, errMsg: '' }
+ }
+ static getDerivedStateFromError(error: unknown) {
+ return {
+ hasError: true,
+ errMsg: error instanceof Error ? error.message : 'Unknown error',
+ }
+ }
+
+ render() {
+ if (this.state.hasError) {
+ return {this.state.errMsg}
+ }
+ return <>{this.props.children}>
+ }
+ }
+
+ function FilterList() {
+ const nestedArg = {
+ kind: 'berries',
+ sort: { field: 'created_at', order: 'ASC' },
+ }
+
+ const result = api.endpoints.getError.useQuery(nestedArg)
+
+ return (
+
+
{String(result.isFetching)}
+
+ )
+ }
+
+ test('Throws an error early upon an infinite loop due to nested args', async () => {
+ render(
+
+
+ ,
+ { wrapper: storeRef.wrapper }
+ )
+
+ await screen.findByText(
+ /The Query argument has been detected as changing too many times within a short period/i
+ )
+ })
+})