Skip to content

Commit 84b7fc5

Browse files
feat(eslint-plugin-query): add rule that disallows putting the result of useMutation directly in a React hook dependency array
1 parent 59a6d3d commit 84b7fc5

File tree

3 files changed

+175
-0
lines changed

3 files changed

+175
-0
lines changed
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { RuleTester } from '@typescript-eslint/rule-tester'
2+
import {
3+
reactHookNames,
4+
rule,
5+
} from '../rules/no-mutation-in-deps/no-mutation-in-deps.rule'
6+
7+
const ruleTester = new RuleTester({
8+
parser: '@typescript-eslint/parser',
9+
settings: {},
10+
})
11+
12+
const baseTestCases = {
13+
valid: (importStatement: string, hookInvocation: string) => ({
14+
name: `should pass when destructured mutate is passed to useCallback as dependency - ${importStatement} - ${hookInvocation}`,
15+
code: `
16+
${importStatement}
17+
import { useMutation } from "@tanstack/react-query";
18+
19+
function Component() {
20+
const { mutate } = useMutation({ mutationFn: (value: string) => value });
21+
const callback = ${hookInvocation}(() => { mutate('hello') }, [mutate]);
22+
return;
23+
}
24+
`,
25+
}),
26+
invalid: (importStatement: string, hookInvocation: string) => ({
27+
name: `result of useMutation is passed to useCallback as dependency - ${importStatement} - ${hookInvocation}`,
28+
code: `
29+
${importStatement}
30+
import { useMutation } from "@tanstack/react-query";
31+
32+
function Component() {
33+
const mutation = useMutation({ mutationFn: (value: string) => value });
34+
const callback = ${hookInvocation}(() => { mutation.mutate('hello') }, [mutation]);
35+
return;
36+
}
37+
`,
38+
errors: [{ messageId: 'mutationInDeps' }],
39+
}),
40+
}
41+
42+
const testCases = (hook: string) => [
43+
{
44+
importStatement: 'import * as React from "React";',
45+
hookInvocation: `React.${hook}`,
46+
},
47+
{
48+
importStatement: `import { ${hook} } from "React";`,
49+
hookInvocation: hook,
50+
},
51+
{
52+
importStatement: `import { ${hook} as useAlias } from "React";`,
53+
hookInvocation: 'useAlias',
54+
},
55+
]
56+
57+
reactHookNames.forEach((hookName) => {
58+
testCases(hookName).forEach(({ importStatement, hookInvocation }) => {
59+
ruleTester.run('no-mutation-in-deps', rule, {
60+
valid: [baseTestCases.valid(importStatement, hookInvocation)],
61+
invalid: [baseTestCases.invalid(importStatement, hookInvocation)],
62+
})
63+
})
64+
})

packages/eslint-plugin-query/src/rules.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import * as exhaustiveDeps from './rules/exhaustive-deps/exhaustive-deps.rule'
22
import * as stableQueryClient from './rules/stable-query-client/stable-query-client.rule'
33
import * as noRestDestructuring from './rules/no-rest-destructuring/no-rest-destructuring.rule'
4+
import * as noMutationInDeps from './rules/no-mutation-in-deps/no-mutation-in-deps.rule'
45
import type { ESLintUtils } from '@typescript-eslint/utils'
56
import type { ExtraRuleDocs } from './types'
67

@@ -16,4 +17,5 @@ export const rules: Record<
1617
[exhaustiveDeps.name]: exhaustiveDeps.rule,
1718
[stableQueryClient.name]: stableQueryClient.rule,
1819
[noRestDestructuring.name]: noRestDestructuring.rule,
20+
[noMutationInDeps.name]: noMutationInDeps.rule,
1921
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { AST_NODE_TYPES, ESLintUtils } from '@typescript-eslint/utils'
2+
import { getDocsUrl } from '../../utils/get-docs-url'
3+
import { detectTanstackQueryImports } from '../../utils/detect-react-query-imports'
4+
import type { TSESTree } from '@typescript-eslint/utils'
5+
import type { ExtraRuleDocs } from '../../types'
6+
7+
export const name = 'no-mutation-in-deps'
8+
9+
export const reactHookNames = ['useEffect', 'useCallback', 'useMemo']
10+
11+
const createRule = ESLintUtils.RuleCreator<ExtraRuleDocs>(getDocsUrl)
12+
13+
export const rule = createRule({
14+
name,
15+
meta: {
16+
type: 'problem',
17+
docs: {
18+
description:
19+
'Disallow putting the result of useMutation directly in a React hook dependency array',
20+
recommended: 'error',
21+
},
22+
messages: {
23+
mutationInDeps: `The result of useMutation is not referentially stable, so don't pass it directly into the dependencies array of a hook like useEffect, useMemo, or useCallback. Instead, destructure the return value of useMutation and pass the destructured values into the dependency array.`,
24+
},
25+
schema: [],
26+
},
27+
defaultOptions: [],
28+
29+
create: detectTanstackQueryImports((context) => {
30+
const trackedVariables = new Set<string>()
31+
const hookAliasMap: Record<string, string> = {}
32+
33+
function isReactHook(node: TSESTree.CallExpression): boolean {
34+
if (node.callee.type === 'Identifier') {
35+
const calleeName = node.callee.name
36+
// Check if the identifier is a known React hook or an alias
37+
return reactHookNames.includes(calleeName) || calleeName in hookAliasMap
38+
} else if (
39+
node.callee.type === 'MemberExpression' &&
40+
node.callee.object.type === 'Identifier' &&
41+
node.callee.object.name === 'React' &&
42+
node.callee.property.type === 'Identifier' &&
43+
reactHookNames.includes(node.callee.property.name)
44+
) {
45+
// Member expression case: `React.useCallback`
46+
return true
47+
}
48+
return false
49+
}
50+
51+
function collectVariableNames(pattern: TSESTree.BindingName) {
52+
if (pattern.type === AST_NODE_TYPES.Identifier) {
53+
trackedVariables.add(pattern.name)
54+
}
55+
}
56+
57+
return {
58+
ImportDeclaration(node: TSESTree.ImportDeclaration) {
59+
if (
60+
node.specifiers.length > 0 &&
61+
node.importKind === 'value' &&
62+
node.source.value === 'React'
63+
) {
64+
node.specifiers.forEach((specifier) => {
65+
if (
66+
specifier.type === AST_NODE_TYPES.ImportSpecifier &&
67+
reactHookNames.includes(specifier.imported.name)
68+
) {
69+
// Track alias or direct import
70+
hookAliasMap[specifier.local.name] = specifier.imported.name
71+
}
72+
})
73+
}
74+
},
75+
76+
VariableDeclarator(node) {
77+
if (
78+
node.init !== null &&
79+
node.init.type === AST_NODE_TYPES.CallExpression &&
80+
node.init.callee.type === AST_NODE_TYPES.Identifier &&
81+
node.init.callee.name === 'useMutation'
82+
) {
83+
collectVariableNames(node.id)
84+
}
85+
},
86+
CallExpression: (node) => {
87+
if (
88+
isReactHook(node) &&
89+
node.arguments.length > 1 &&
90+
node.arguments[1]?.type === AST_NODE_TYPES.ArrayExpression
91+
) {
92+
const depsArray = node.arguments[1].elements
93+
depsArray.forEach((dep) => {
94+
if (
95+
dep !== null &&
96+
dep.type === AST_NODE_TYPES.Identifier &&
97+
trackedVariables.has(dep.name)
98+
) {
99+
context.report({
100+
node: dep,
101+
messageId: 'mutationInDeps',
102+
})
103+
}
104+
})
105+
}
106+
},
107+
}
108+
}),
109+
})

0 commit comments

Comments
 (0)