Skip to content
1 change: 1 addition & 0 deletions packages/react-doctor/src/oxlint-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,7 @@ export const GLOBAL_REACT_DOCTOR_RULES: Record<string, RuleSeverity> = {
"react-doctor/no-cascading-set-state": "warn",
"react-doctor/no-effect-event-handler": "warn",
"react-doctor/no-effect-event-in-deps": "error",
"react-doctor/no-event-trigger-state": "warn",
"react-doctor/no-prop-callback-in-effect": "warn",
"react-doctor/no-derived-useState": "warn",
"react-doctor/no-direct-state-mutation": "warn",
Expand Down
50 changes: 50 additions & 0 deletions packages/react-doctor/src/plugin/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,56 @@ export const MUTATING_ROUTE_SEGMENTS = new Set([

export const EFFECT_HOOK_NAMES = new Set(["useEffect", "useLayoutEffect"]);
export const HOOKS_WITH_DEPS = new Set(["useEffect", "useLayoutEffect", "useMemo", "useCallback"]);

// Used by `no-event-trigger-state` to recognize when a useEffect body
// is performing the §6 anti-pattern from "You Might Not Need an Effect"
// — running an event-shaped side effect (POST, navigation, notification,
// analytics) that the user actually triggered with a button click.
// Tightly scoped on purpose — adding a callee name here can produce
// false positives on pure helper functions, so the bar is "this name
// almost always denotes a fire-and-forget user-action effect."
export const EVENT_TRIGGERED_SIDE_EFFECT_CALLEES = new Set([
// Network shorthand verbs (article uses `post`)
"fetch",
"post",
"put",
"patch",
"del",
// Common HTTP client wrappers
"ky",
"got",
"wretch",
"ofetch",
// Navigation
"navigate",
"navigateTo",
// UI side effects
"showNotification",
"toast",
"alert",
"confirm",
// Analytics
"track",
"logEvent",
"logVisit",
"captureEvent",
]);

// Recognized when the call shape is `<obj>.<method>(...)` — covers
// `axios.post`, `api.post`, `router.push`, `analytics.track`,
// `posthog.capture`, etc. without enumerating every possible object.
export const EVENT_TRIGGERED_SIDE_EFFECT_MEMBER_METHODS = new Set([
"post",
"put",
"patch",
"delete",
"push",
"replace",
"navigate",
"capture",
"track",
"logEvent",
]);
export const CHAINABLE_ITERATION_METHODS = new Set(["map", "filter", "forEach", "flatMap"]);
export const STORAGE_OBJECTS = new Set(["localStorage", "sessionStorage"]);

Expand Down
2 changes: 2 additions & 0 deletions packages/react-doctor/src/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ import {
noDirectStateMutation,
noEffectEventHandler,
noEffectEventInDeps,
noEventTriggerState,
noFetchInEffect,
noPropCallbackInEffect,
noSetStateInRender,
Expand All @@ -203,6 +204,7 @@ const plugin: RulePlugin = {
"no-cascading-set-state": noCascadingSetState,
"no-effect-event-handler": noEffectEventHandler,
"no-effect-event-in-deps": noEffectEventInDeps,
"no-event-trigger-state": noEventTriggerState,
"no-prop-callback-in-effect": noPropCallbackInEffect,
"no-derived-useState": noDerivedUseState,
"no-direct-state-mutation": noDirectStateMutation,
Expand Down
185 changes: 185 additions & 0 deletions packages/react-doctor/src/plugin/rules/state-and-effects.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import {
CASCADING_SET_STATE_THRESHOLD,
EFFECT_HOOK_NAMES,
EVENT_TRIGGERED_SIDE_EFFECT_CALLEES,
EVENT_TRIGGERED_SIDE_EFFECT_MEMBER_METHODS,
HOOKS_WITH_DEPS,
MUTATING_ARRAY_METHODS,
RELATED_USE_STATE_THRESHOLD,
Expand Down Expand Up @@ -1153,3 +1155,186 @@ export const noSetStateInRender: Rule = {
};
},
};

// HACK: §6 of "You Might Not Need an Effect" — sending a POST request:
//
// const [jsonToSubmit, setJsonToSubmit] = useState(null);
// useEffect(() => {
// if (jsonToSubmit !== null) {
// post('/api/register', jsonToSubmit);
// }
// }, [jsonToSubmit]);
//
// function handleSubmit(event) {
// event.preventDefault();
// setJsonToSubmit({ firstName, lastName }); // ← only writer
// }
//
// Detector pre-conditions (all must hold):
// (1) useEffect with deps = [stateX] — single dep that's a useState
// binding declared in this component
// (2) effect body is a single IfStatement guarding on stateX with one
// of: bare truthy, !== null/undefined, === Literal, or .length
// (3) IfStatement.consequent contains a CallExpression whose callee
// is in EVENT_TRIGGERED_SIDE_EFFECT_CALLEES OR a MemberExpression
// whose property is in EVENT_TRIGGERED_SIDE_EFFECT_MEMBER_METHODS
// (4) every setStateX call site is inside a JSX `on*` handler (or a
// function bound to one) — i.e. the trigger is set only by user
// interactions, never by other reactive logic
//
// Why all four matter: (1) + (2) recognize the "trigger guard" shape;
// (3) restricts to side effects users would associate with a button
// click; (4) is the strongest signal that the state exists *only* to
// schedule the effect, distinguishing this from §5 (event-shared logic
// triggered by props) which already has its own rule.
const getTriggerGuardRootName = (testNode: EsTreeNode): string | null => {
if (!testNode) return null;
if (testNode.type === "Identifier") return testNode.name;
if (testNode.type === "BinaryExpression") {
if (!["!==", "===", "!=", "=="].includes(testNode.operator)) return null;
const sides = [testNode.left, testNode.right];
for (const side of sides) {
if (side?.type === "Identifier") return side.name;
}
return null;
Comment thread
cursor[bot] marked this conversation as resolved.
}
if (
testNode.type === "MemberExpression" &&
testNode.property?.type === "Identifier" &&
testNode.property.name === "length"
) {
if (testNode.object?.type === "Identifier") return testNode.object.name;
}
if (testNode.type === "UnaryExpression" && testNode.operator === "!") {
return getTriggerGuardRootName(testNode.argument);
}
return null;
};

const findTriggeredSideEffectCalleeName = (consequentNode: EsTreeNode): string | null => {
let foundCalleeName: string | null = null;
walkAst(consequentNode, (child: EsTreeNode) => {
if (foundCalleeName) return false;
if (child.type !== "CallExpression") return;
const callee = child.callee;
if (callee?.type === "Identifier" && EVENT_TRIGGERED_SIDE_EFFECT_CALLEES.has(callee.name)) {
foundCalleeName = callee.name;
return;
}
if (
callee?.type === "MemberExpression" &&
callee.property?.type === "Identifier" &&
EVENT_TRIGGERED_SIDE_EFFECT_MEMBER_METHODS.has(callee.property.name)
) {
let cursor: EsTreeNode | undefined = callee;
while (cursor?.type === "MemberExpression") cursor = cursor.object;
const rootName = cursor?.type === "Identifier" ? cursor.name : null;
foundCalleeName = rootName ? `${rootName}.${callee.property.name}` : callee.property.name;
}
});
return foundCalleeName;
};

const collectHandlerOnlyWriteStateNames = (
componentBody: EsTreeNode,
useStateBindings: Array<{ valueName: string; setterName: string; declarator: EsTreeNode }>,
handlerBindingNames: Set<string>,
): Set<string> => {
const handlerOnlyWriteStateNames = new Set<string>();
for (const binding of useStateBindings) {
let didFindAnySetterCall = false;
let areAllSetterCallsInHandlers = true;
walkAst(componentBody, (child: EsTreeNode) => {
if (!areAllSetterCallsInHandlers) return false;
if (child.type !== "CallExpression") return;
if (child.callee?.type !== "Identifier") return;
if (child.callee.name !== binding.setterName) return;
didFindAnySetterCall = true;
if (!isInsideEventHandler(child, handlerBindingNames)) {
areAllSetterCallsInHandlers = false;
}
});
if (didFindAnySetterCall && areAllSetterCallsInHandlers) {
handlerOnlyWriteStateNames.add(binding.valueName);
}
}
return handlerOnlyWriteStateNames;
};

export const noEventTriggerState: Rule = {
create: (context: RuleContext) => {
const checkComponent = (componentBody: EsTreeNode | null | undefined): void => {
if (!componentBody || componentBody.type !== "BlockStatement") return;

const useStateBindings = collectUseStateBindings(componentBody);
if (useStateBindings.length === 0) return;

const handlerBindingNames = collectHandlerBindingNames(componentBody);
const handlerOnlyWriteStateNames = collectHandlerOnlyWriteStateNames(
componentBody,
useStateBindings,
handlerBindingNames,
);
if (handlerOnlyWriteStateNames.size === 0) return;

// HACK: a state read in render (e.g. `<input value={query} />`)
// is dual-purpose — it controls UI AND triggers the effect.
// Calling it "exists only to schedule the effect" is wrong; the
// user can't just delete the state. Reuse the same render-
// reachability machinery that `rerenderStateOnlyInHandlers`
// uses to filter these out (transitive dep graph + walk from
// return expressions).
const returnExpressions = collectReturnExpressions(componentBody);
const dependencyGraph = buildLocalDependencyGraph(componentBody);
const directRenderNames = collectRenderReachableNames(returnExpressions);
const renderReachableNames = expandTransitiveDependencies(directRenderNames, dependencyGraph);

walkAst(componentBody, (effectCall: EsTreeNode) => {
if (effectCall.type !== "CallExpression") return;
if (!isHookCall(effectCall, EFFECT_HOOK_NAMES)) return;
if ((effectCall.arguments?.length ?? 0) < 2) return;

const depsNode = effectCall.arguments[1];
if (depsNode.type !== "ArrayExpression") return;
if ((depsNode.elements?.length ?? 0) !== 1) return;

const depElement = depsNode.elements[0];
if (depElement?.type !== "Identifier") return;
if (!handlerOnlyWriteStateNames.has(depElement.name)) return;
// Dual-purpose state — used in render too. Don't claim it
// "exists only to schedule" the effect.
if (renderReachableNames.has(depElement.name)) return;

const callback = getEffectCallback(effectCall);
if (!callback) return;

const bodyStatements = getCallbackStatements(callback);
if (bodyStatements.length !== 1) return;
const soleStatement = bodyStatements[0];
if (soleStatement.type !== "IfStatement") return;

const guardRootName = getTriggerGuardRootName(soleStatement.test);
if (guardRootName !== depElement.name) return;

const sideEffectCalleeName = findTriggeredSideEffectCalleeName(soleStatement.consequent);
if (!sideEffectCalleeName) return;

context.report({
node: effectCall,
message: `useState "${depElement.name}" exists only to schedule "${sideEffectCalleeName}(...)" from a useEffect — call "${sideEffectCalleeName}(...)" directly inside the event handler that sets it, and delete the state`,
});
});
Comment thread
cursor[bot] marked this conversation as resolved.
};
Comment thread
cursor[bot] marked this conversation as resolved.

return {
FunctionDeclaration(node: EsTreeNode) {
if (!node.id?.name || !isUppercaseName(node.id.name)) return;
checkComponent(node.body);
},
VariableDeclarator(node: EsTreeNode) {
if (!isComponentAssignment(node)) return;
checkComponent(node.init?.body);
},
};
},
};
3 changes: 3 additions & 0 deletions packages/react-doctor/src/utils/run-oxlint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ const RULE_CATEGORY_MAP: Record<string, string> = {
"react-doctor/no-cascading-set-state": "State & Effects",
"react-doctor/no-effect-event-handler": "State & Effects",
"react-doctor/no-effect-event-in-deps": "State & Effects",
"react-doctor/no-event-trigger-state": "State & Effects",
"react-doctor/no-prop-callback-in-effect": "State & Effects",
"react-doctor/no-derived-useState": "State & Effects",
"react-doctor/no-direct-state-mutation": "State & Effects",
Expand Down Expand Up @@ -242,6 +243,8 @@ const RULE_HELP_MAP: Record<string, string> = {
"Combine into useReducer: `const [state, dispatch] = useReducer(reducer, initialState)`",
"no-effect-event-handler":
"Move the conditional logic into onClick, onChange, or onSubmit handlers directly",
"no-event-trigger-state":
"Delete the trigger state (`useState(null)` plus the `useEffect` that watches it) and call the side-effect (`post(...)` / `navigate(...)` / `track(...)`) directly inside the event handler that previously called the setter. State should not exist purely to schedule effect runs",
"no-derived-useState":
"Remove useState and compute the value inline: `const value = transform(propName)`",
"no-direct-state-mutation":
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,28 @@ const ConditionalSetStateInRenderComponent = ({ count }: { count: number }) => {
return <h1>{prevCount}</h1>;
};

declare const post: (url: string, body: unknown) => void;

const EventTriggerStateComponent = () => {
const [firstName, setFirstName] = useState("");
const [jsonToSubmit, setJsonToSubmit] = useState<{ firstName: string } | null>(null);
useEffect(() => {
if (jsonToSubmit !== null) {
post("/api/register", jsonToSubmit);
}
}, [jsonToSubmit]);
return (
<form
onSubmit={(event) => {
event.preventDefault();
setJsonToSubmit({ firstName });
}}
>
<input value={firstName} onChange={(event) => setFirstName(event.target.value)} />
</form>
);
};

const UncontrolledInputComponent = () => {
// HACK: explicit `<string | undefined>` keeps TypeScript happy while the
// RUNTIME initializer stays undefined — that's what trips the
Expand Down Expand Up @@ -169,5 +191,6 @@ export {
DirectStateMutationComponent,
SetStateInRenderComponent,
ConditionalSetStateInRenderComponent,
EventTriggerStateComponent,
UncontrolledInputComponent,
};
Loading
Loading