Skip to content

[eslint-plugin-react-hooks] add experimental_autoDependenciesHooks option #33294

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
May 19, 2025
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -515,6 +515,22 @@ const tests = {
`,
options: [{additionalHooks: 'useCustomEffect'}],
},
{
// behaves like no deps
code: normalizeIndent`
function MyComponent(props) {
useSpecialEffect(() => {
console.log(props.foo);
}, null);
}
`,
options: [
{
additionalHooks: 'useSpecialEffect',
experimental_autoDependenciesHooks: ['useSpecialEffect'],
},
],
},
{
code: normalizeIndent`
function MyComponent(props) {
Expand Down Expand Up @@ -1470,6 +1486,38 @@ const tests = {
},
],
invalid: [
{
code: normalizeIndent`
function MyComponent(props) {
useSpecialEffect(() => {
console.log(props.foo);
}, null);
}
`,
options: [{additionalHooks: 'useSpecialEffect'}],
errors: [
{
message:
"React Hook useSpecialEffect was passed a dependency list that is not an array literal. This means we can't statically verify whether you've passed the correct dependencies.",
},
{
message:
"React Hook useSpecialEffect has a missing dependency: 'props.foo'. Either include it or remove the dependency array.",
suggestions: [
{
desc: 'Update the dependencies array to be: [props.foo]',
output: normalizeIndent`
function MyComponent(props) {
useSpecialEffect(() => {
console.log(props.foo);
}, [props.foo]);
}
`,
},
],
},
],
},
{
code: normalizeIndent`
function MyComponent(props) {
Expand Down Expand Up @@ -7821,6 +7869,24 @@ const testsTypescript = {
}
`,
},
{
code: normalizeIndent`
function MyComponent() {
const [state, setState] = React.useState<number>(0);

useSpecialEffect(() => {
const someNumber: typeof state = 2;
setState(prevState => prevState + someNumber);
})
}
`,
options: [
{
additionalHooks: 'useSpecialEffect',
experimental_autoDependenciesHooks: ['useSpecialEffect'],
},
],
},
{
code: normalizeIndent`
function App() {
Expand Down Expand Up @@ -8176,6 +8242,48 @@ const testsTypescript = {
function MyComponent() {
const [state, setState] = React.useState<number>(0);

useSpecialEffect(() => {
const someNumber: typeof state = 2;
setState(prevState => prevState + someNumber + state);
}, [])
}
`,
options: [
{
additionalHooks: 'useSpecialEffect',
experimental_autoDependenciesHooks: ['useSpecialEffect'],
},
],
errors: [
{
message:
"React Hook useSpecialEffect has a missing dependency: 'state'. " +
'Either include it or remove the dependency array. ' +
`You can also do a functional update 'setState(s => ...)' ` +
`if you only need 'state' in the 'setState' call.`,
suggestions: [
{
desc: 'Update the dependencies array to be: [state]',
output: normalizeIndent`
function MyComponent() {
const [state, setState] = React.useState<number>(0);

useSpecialEffect(() => {
const someNumber: typeof state = 2;
setState(prevState => prevState + someNumber + state);
}, [state])
}
`,
},
],
},
],
},
{
code: normalizeIndent`
function MyComponent() {
const [state, setState] = React.useState<number>(0);

useMemo(() => {
const someNumber: typeof state = 2;
console.log(someNumber);
Expand Down
59 changes: 49 additions & 10 deletions packages/eslint-plugin-react-hooks/src/rules/ExhaustiveDeps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,27 +61,38 @@ const rule = {
enableDangerousAutofixThisMayCauseInfiniteLoops: {
type: 'boolean',
},
experimental_autoDependenciesHooks: {
type: 'array',
items: {
type: 'string',
},
},
},
},
],
},
create(context: Rule.RuleContext) {
const rawOptions = context.options && context.options[0];

// Parse the `additionalHooks` regex.
const additionalHooks =
context.options &&
context.options[0] &&
context.options[0].additionalHooks
? new RegExp(context.options[0].additionalHooks)
rawOptions && rawOptions.additionalHooks
? new RegExp(rawOptions.additionalHooks)
: undefined;

const enableDangerousAutofixThisMayCauseInfiniteLoops: boolean =
(context.options &&
context.options[0] &&
context.options[0].enableDangerousAutofixThisMayCauseInfiniteLoops) ||
(rawOptions &&
rawOptions.enableDangerousAutofixThisMayCauseInfiniteLoops) ||
false;

const experimental_autoDependenciesHooks: ReadonlyArray<string> =
rawOptions && Array.isArray(rawOptions.experimental_autoDependenciesHooks)
? rawOptions.experimental_autoDependenciesHooks
: [];

const options = {
additionalHooks,
experimental_autoDependenciesHooks,
enableDangerousAutofixThisMayCauseInfiniteLoops,
};

Expand Down Expand Up @@ -162,6 +173,7 @@ const rule = {
reactiveHook: Node,
reactiveHookName: string,
isEffect: boolean,
isAutoDepsHook: boolean,
): void {
if (isEffect && node.async) {
reportProblem({
Expand Down Expand Up @@ -649,6 +661,9 @@ const rule = {
}

if (!declaredDependenciesNode) {
if (isAutoDepsHook) {
return;
}
// Check if there are any top-level setState() calls.
// Those tend to lead to infinite loops.
let setStateInsideEffectWithoutDeps: string | null = null;
Expand Down Expand Up @@ -711,6 +726,13 @@ const rule = {
}
return;
}
if (
isAutoDepsHook &&
declaredDependenciesNode.type === 'Literal' &&
declaredDependenciesNode.value === null
) {
return;
}

const declaredDependencies: Array<DeclaredDependency> = [];
const externalDependencies = new Set<string>();
Expand Down Expand Up @@ -1318,10 +1340,19 @@ const rule = {
return;
}

const isAutoDepsHook =
options.experimental_autoDependenciesHooks.includes(reactiveHookName);

// Check the declared dependencies for this reactive hook. If there is no
// second argument then the reactive callback will re-run on every render.
// So no need to check for dependency inclusion.
if (!declaredDependenciesNode && !isEffect) {
if (
(!declaredDependenciesNode ||
(isAutoDepsHook &&
declaredDependenciesNode.type === 'Literal' &&
declaredDependenciesNode.value === null)) &&
!isEffect
) {
// These are only used for optimization.
if (
reactiveHookName === 'useMemo' ||
Expand Down Expand Up @@ -1355,11 +1386,17 @@ const rule = {
reactiveHook,
reactiveHookName,
isEffect,
isAutoDepsHook,
);
return; // Handled
case 'Identifier':
if (!declaredDependenciesNode) {
// No deps, no problems.
if (
!declaredDependenciesNode ||
(isAutoDepsHook &&
declaredDependenciesNode.type === 'Literal' &&
declaredDependenciesNode.value === null)
) {
// Always runs, no problems.
return; // Handled
}
// The function passed as a callback is not written inline.
Expand Down Expand Up @@ -1408,6 +1445,7 @@ const rule = {
reactiveHook,
reactiveHookName,
isEffect,
isAutoDepsHook,
);
return; // Handled
case 'VariableDeclarator':
Expand All @@ -1427,6 +1465,7 @@ const rule = {
reactiveHook,
reactiveHookName,
isEffect,
isAutoDepsHook,
);
return; // Handled
}
Expand Down
Loading