logoESLint React
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):

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.`,
            });
          }
        }
      },
    };
  };
}
eslint.config.ts
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.

On this page