no-circular-effect
Detects circular dependencies between useEffect hooks. Prevents infinite update loops caused by effects that set state they also depend on.
In most cases, use the built-in set-state-in-effect rule instead. It's also part of React's official lints. Use this custom recipe only when you need to detect circular dependencies across multiple effects.
Overview
This rule detects when useEffect-like hooks form a cycle through state variables. For example, if an effect depends on state A and sets state B, while another effect depends on state B and sets state A, it creates an infinite update loop. It builds a directed graph of state dependencies across all effects in a component and reports any effect that participates in a cycle.
Rule Definition
Copy the following rule definition into your project (e.g. eslint.config.rules.ts):
import type { RuleDefinition } from "@eslint-react/kit";
import type { TSESTree } from "@typescript-eslint/utils";
import { findVariable } from "@typescript-eslint/utils/ast-utils";
import type { Scope } from "@typescript-eslint/utils/ts-eslint";
/** Detect circular dependencies between useEffect hooks via useState setters. */
export function noCircularEffect(): RuleDefinition {
return (context, { is, settings }) => {
const { additionalStateHooks, additionalEffectHooks } = settings;
// Map: setter Scope.Variable → state Scope.Variable
const setterToState = new Map<Scope.Variable, Scope.Variable>();
// Pending useEffect-like calls to process at Program:exit
const pendingEffects: TSESTree.CallExpression[] = [];
return {
CallExpression(node: TSESTree.CallExpression) {
// 1. Register useState pairs
if (is.useStateLikeCall(node, additionalStateHooks)) {
const { parent } = node;
if (parent.type === "VariableDeclarator" && parent.id.type === "ArrayPattern") {
const [stateEl, setterEl] = parent.id.elements;
if (stateEl?.type === "Identifier" && setterEl?.type === "Identifier") {
const scope = context.sourceCode.getScope(node);
const stateVar = findVariable(scope, stateEl.name);
const setterVar = findVariable(scope, setterEl.name);
if (stateVar != null && setterVar != null) {
setterToState.set(setterVar, stateVar);
}
}
}
return;
}
// 2. Collect useEffect-like calls
if (is.useEffectLikeCall(node, additionalEffectHooks)) {
pendingEffects.push(node);
}
},
"Program:exit"() {
interface EffectEdge {
deps: Scope.Variable[];
targets: Scope.Variable[];
node: TSESTree.CallExpression;
}
const stateVars = new Set(setterToState.values());
const edges: EffectEdge[] = [];
for (const node of pendingEffects) {
const callback = node.arguments[0];
const depsArg = node.arguments[1];
if (callback == null || depsArg == null) continue;
// Extract dependency state variables from the deps array
const deps: Scope.Variable[] = [];
if (depsArg.type === "ArrayExpression") {
for (const el of depsArg.elements) {
if (el?.type === "Identifier") {
const scope = context.sourceCode.getScope(el);
const v = findVariable(scope, el.name);
if (v != null && stateVars.has(v)) {
deps.push(v);
}
}
}
}
if (deps.length === 0) continue;
// Find setter calls inside the callback body
const targets: Scope.Variable[] = [];
const [cbStart, cbEnd] = callback.range;
for (const [setterVar, stateVar] of setterToState) {
for (const ref of setterVar.references) {
const [refStart, refEnd] = ref.identifier.range;
if (refStart < cbStart || refEnd > cbEnd) continue;
const { parent } = ref.identifier;
if (parent?.type === "CallExpression" && parent.callee === ref.identifier) {
targets.push(stateVar);
break;
}
}
}
if (targets.length === 0) continue;
edges.push({ deps, targets, node });
}
// Build a directed graph: stateVar → Set<stateVar>
// If an effect depends on A and sets B, add edge A → B
const graph = new Map<Scope.Variable, Set<Scope.Variable>>();
for (const { deps, targets } of edges) {
for (const dep of deps) {
for (const target of targets) {
if (!graph.has(dep)) graph.set(dep, new Set());
graph.get(dep)!.add(target);
}
}
}
// Detect cycles via DFS
const visited = new Set<Scope.Variable>();
const inStack = new Set<Scope.Variable>();
const inCycle = new Set<Scope.Variable>();
function dfs(v: Scope.Variable): boolean {
if (inStack.has(v)) return true;
if (visited.has(v)) return false;
visited.add(v);
inStack.add(v);
let foundCycle = false;
for (const neighbor of graph.get(v) ?? []) {
if (dfs(neighbor)) {
inCycle.add(v);
inCycle.add(neighbor);
foundCycle = true;
}
}
inStack.delete(v);
return foundCycle;
}
for (const v of graph.keys()) dfs(v);
if (inCycle.size === 0) return;
// Report each effect that participates in a cycle
for (const { deps, targets, node } of edges) {
const cycleDeps = deps.filter((d) => inCycle.has(d));
const cycleTargets = targets.filter((t) => inCycle.has(t));
if (cycleDeps.length === 0 || cycleTargets.length === 0) continue;
const depNames = cycleDeps.map((d) => d.name).join(", ");
const targetNames = cycleTargets.map((t) => t.name).join(", ");
context.report({
node,
message:
`Circular effect detected: this effect depends on [${depNames}] and updates [${targetNames}], creating an infinite update loop.`,
});
}
},
};
};
}import eslintReactKit from "@eslint-react/kit";
import { noCircularEffect } from "./eslint.config.rules";
export default [
// ... other configs
{
...eslintReactKit()
.use(noCircularEffect)
.getConfig(),
files: ["src/**/*.tsx"],
},
];Invalid
Circular effect with depth 1. An effect depends on items and also sets items:
import { useEffect, useState } from "react";
function CircularEffect1() {
const [items, setItems] = useState([0, 1, 2, 3, 4]);
useEffect(() => {
setItems(x => [...x].reverse());
}, [items]);
return null;
}Circular effect with depth 2. Two effects form a cycle: one depends on limit and sets items, and another depends on items and sets limit:
import { useEffect, useState } from "react";
function CircularEffect2() {
const [items, setItems] = useState([0, 1, 2, 3, 4]);
const [limit, setLimit] = useState(false);
useEffect(() => {
setItems(x => [...x].reverse());
}, [limit]);
useEffect(() => {
setLimit(x => !x);
}, [items]);
return null;
}Circular effect with depth 3. Three effects form a cycle through items → count → limit → items:
import { useEffect, useState } from "react";
function CircularEffect3() {
const [items, setItems] = useState([0, 1, 2, 3, 4]);
const [limit, setLimit] = useState(false);
const [count, setCount] = useState(0);
useEffect(() => {
setItems(x => [...x].reverse());
}, [limit]);
useEffect(() => {
setCount(x => x + 1);
}, [items]);
useEffect(() => {
setLimit(x => !x);
}, [count]);
return null;
}Valid
Effects without circular dependencies:
import { useEffect, useState } from "react";
function ValidComponent() {
const [count, setCount] = useState(0);
const [label, setLabel] = useState("");
// ✅ Depends on `count`, sets `label`. No cycle.
useEffect(() => {
setLabel(`Count is ${count}`);
}, [count]);
return null;
}Effects with no dependency array:
import { useEffect, useState } from "react";
function ValidComponent2() {
const [count, setCount] = useState(0);
// ✅ No dependency array. Not a circular pattern.
useEffect(() => {
setCount(0);
});
return null;
}