Custom Rules Of State
Validates state usage. Prefer the updater function form in useState setters
Overview
This recipe contains one custom rule for validating state usage:
preferStateUpdaterFunction: Reports when auseStatesetter 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.
preferStateUpdaterFunction
Copy the following code into your project (e.g. .config/preferStateUpdaterFunction.ts):
import type { RuleFunction } from "@eslint-react/kit";
import type { ScopeVariable } from "@typescript-eslint/scope-manager";
import type { TSESTree } from "@typescript-eslint/types";
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(): RuleFunction {
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.`,
});
}
}
},
};
};
}Config
import eslintReactKit from "@eslint-react/kit";
import { preferStateUpdaterFunction } from "./.config/preferStateUpdaterFunction";
export default [
// ... other configs
{
...eslintReactKit()
.use(preferStateUpdaterFunction)
.getConfig(),
files: ["src/**/*.tsx"],
},
];Examples
Invalid
import { useState } from "react";
function Counter() {
const [count, setCount] = useState(0);
// ❌ Referencing `count` directly. Stale state risk.
return (
<button onClick={() => setCount(count + 1)}>
{/* ^^^ Do not reference 'setCount' directly; use the updater function form instead. */}
{count}
</button>
);
}import { useState } from "react";
function Toggle() {
const [enabled, setEnabled] = useState(false);
// ❌ Negating state directly.
return (
<button onClick={() => setEnabled(!enabled)}>
{/* ^^^ Do not reference 'setEnabled' directly; use the updater function form instead. */ }
{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 });
// ^^^ Do not reference 'setUser' directly; use the updater function form instead.
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"]);
// ^^^ Do not reference 'setItems' directly; use the updater function form instead.
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 />;
}Using All Rules
To use all state-related rules together:
import eslintReactKit from "@eslint-react/kit";
import {
preferStateUpdaterFunction,
// Additional state rules can be added here
} from "./.config";
export default [
// ... other configs
{
...eslintReactKit()
.use(preferStateUpdaterFunction)
.getConfig(),
files: ["src/**/*.tsx"],
},
];Further Reading
Resources
- AST Explorer - A tool for exploring the abstract syntax tree (AST) of JavaScript code, which is essential for writing custom rules.
- ESLint Developer Guide - Official ESLint documentation for creating custom rules.
- Using the TypeScript Compiler API - TypeScript compiler API documentation for working with type information in custom rules.
See Also
custom-rules-of-props
Custom rules for validating JSX props. Includes checks for duplicate props and mixing controlled and uncontrolled props.