Recipes
custom-rules-of-state
Validates state usage. Prefer the updater function form in useState setters.
Overview
Reports when a useState setter is called with an expression that directly references the corresponding state variable. Using the callback (updater function) form ensures the update uses the latest state value, avoiding stale-state bugs in closures, event handlers, and timeouts.
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 { ScopeVariable } from "@typescript-eslint/scope-manager";
import type { TSESTree } from "@typescript-eslint/utils";
import { findVariable } from "@typescript-eslint/utils/ast-utils";
/** Require the updater function form of useState setters when referencing the corresponding state variable. */
export function preferStateUpdaterFunction(): RuleDefinition {
function isFunction({ type }: TSESTree.Node) {
return type === "FunctionDeclaration" || type === "FunctionExpression" || type === "ArrowFunctionExpression";
}
return (context, { is, settings }) => {
const setterToStateVar = new Map<ScopeVariable, ScopeVariable>();
const pendingCalls: { callerVar: ScopeVariable; node: TSESTree.CallExpression }[] = [];
return {
CallExpression(node: TSESTree.CallExpression) {
// Register useState pairs.
if (is.useStateLikeCall(node, settings.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) {
setterToStateVar.set(setterVar, stateVar);
}
}
}
return;
}
// Queue potential setter calls for deferred checking at Program:exit,
// so that useState pairs declared after the setter call are still detected.
if (node.callee.type !== "Identifier") return;
const scope = context.sourceCode.getScope(node);
const callerVar = findVariable(scope, node.callee.name);
if (callerVar != null) {
pendingCalls.push({ callerVar, node });
}
},
"Program:exit"() {
for (const { callerVar, node } of pendingCalls) {
if (!setterToStateVar.has(callerVar)) continue;
const stateVar = setterToStateVar.get(callerVar)!;
const arg = node.arguments[0];
if (arg == null) continue;
// Already using the callback form.
if (isFunction(arg)) continue;
// Check if the argument contains a reference to the state variable.
const [argStart, argEnd] = arg.range!;
const hasStateRef = stateVar.references.some(
(ref) =>
argStart <= ref.identifier.range![0]
&& ref.identifier.range![1] <= argEnd,
);
if (hasStateRef) {
context.report({
node,
message: `Do not reference '${
context.sourceCode.getText(node.callee)
}' directly; use the updater function form instead.`,
});
}
}
},
};
};
}import eslintReactKit from "@eslint-react/kit";
import { preferStateUpdaterFunction } from "./eslint.config.rules";
export default [
// ... other configs
{
...eslintReactKit()
.use(preferStateUpdaterFunction)
.getConfig(),
files: ["src/**/*.tsx"],
},
];Invalid
import { useState } from "react";
function Counter() {
const [count, setCount] = useState(0);
// ❌ Referencing `count` directly. Stale state risk.
return (
<button onClick={() => setCount(count + 1)}>
{count}
</button>
);
}import { useState } from "react";
function Toggle() {
const [enabled, setEnabled] = useState(false);
// ❌ Negating state directly.
return (
<button onClick={() => setEnabled(!enabled)}>
{enabled ? "On" : "Off"}
</button>
);
}import { useState } from "react";
function UserEditor() {
const [user, setUser] = useState({ name: "John", age: 25 });
// ❌ Spreading state directly. Stale state risk.
const updateAge = () => setUser({ ...user, age: 30 });
return <button onClick={updateAge}>Update Age</button>;
}import { useState } from "react";
function ItemList() {
const [items, setItems] = useState(["a", "b"]);
// ❌ Calling a method on state directly. Stale state risk.
const addItem = () => setItems([...items, "c"]);
return <button onClick={addItem}>Add</button>;
}Valid
import { useState } from "react";
function Counter() {
const [count, setCount] = useState(0);
// ✅ Callback form. Always gets the latest state.
return (
<button onClick={() => setCount((prev) => prev + 1)}>
{count}
</button>
);
}import { useState } from "react";
function Component() {
const [count, setCount] = useState(0);
// ✅ Setting a constant value does not reference state.
return <button onClick={() => setCount(0)}>Reset</button>;
}import { useState } from "react";
function Component() {
const [user, setUser] = useState({ name: "John" });
const newUserFromApi = { name: "Jane" };
// ✅ Setting a value that does not reference the state variable.
setUser(newUserFromApi);
return <div />;
}import { useState } from "react";
function Component() {
const [count, setCount] = useState(0);
const [total, setTotal] = useState(100);
// ✅ Referencing a *different* state variable is fine.
setCount(total);
return <div />;
}Further Reading
See Also
custom-rules-of-props
Custom rules for validating JSX props. Includes checks for duplicate props and mixing controlled and uncontrolled props.