feat(react-doctor): add no-event-trigger-state rule#155
Merged
Conversation
New rule (severity: warn) flagging the §6 anti-pattern from React's
'You Might Not Need an Effect' guide:
const [jsonToSubmit, setJsonToSubmit] = useState(null);
useEffect(() => {
if (jsonToSubmit !== null) {
post('/api/register', jsonToSubmit);
}
}, [jsonToSubmit]);
function handleSubmit(event) {
event.preventDefault();
setJsonToSubmit({ firstName, lastName });
}
The state variable exists only to schedule an effect to run on click.
The fix is to call `post('/api/register', { firstName, lastName })`
directly inside handleSubmit and delete the state — and that's exactly
what the rule's diagnostic recommends.
Detector pre-conditions (all four must hold) — chosen to keep
real-world false positives near zero:
(1) useEffect with a single-identifier dep array, where the dep is
a useState binding declared in this component
(2) effect body is exactly one IfStatement guarding on that state
with one of: bare truthy, !== null/undefined, === Literal,
.length, or !X
(3) IfStatement.consequent contains a CallExpression whose callee
is in EVENT_TRIGGERED_SIDE_EFFECT_CALLEES (fetch, post, navigate,
showNotification, alert, track, ...) OR a MemberExpression
whose property is in EVENT_TRIGGERED_SIDE_EFFECT_MEMBER_METHODS
(`axios.post`, `router.push`, `analytics.track`, etc.)
(4) every setStateX call site in the component is inside a JSX
`on*` handler (or a function bound to one) — i.e. the trigger
is set only by user interactions
(4) is the strongest signal that the state exists *only* to schedule
the effect, and is what distinguishes this rule from §5 (handled by
the existing no-effect-event-handler).
Reuses existing helpers `collectHandlerBindingNames` /
`isInsideEventHandler` from the same file.
Tests: 7 regression cases.
flags:
- canonical post-trigger shape
- axios.post member-call shape
- bare truthy guard with navigate(...)
does NOT flag:
- article's GOOD analytics-on-mount example (empty deps, no trigger)
- state also written outside handlers (mixed reactive logic)
- guard with a non-side-effect callee (compute(seed))
- guard on a prop (no useState binding present)
Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
This was referenced May 7, 2026
…d in render
Bugbot found that the rule's four pre-conditions don't include a
render-reachability guard. State that's BOTH a controlled-input
value AND a trigger for the effect (e.g. `query` driving both
`<input value={query}>` and `useEffect(() => track('search', query),
[query])`) satisfied all four conditions and produced a false
positive — the message told the user to 'delete the state', which
would break the input.
Added the render-reachability check used by rerender-state-only-in-
handlers: collect return expressions → expand transitive
dependencies → check if the trigger state is render-reachable. If
yes, skip — it's dual-purpose, not a pure trigger.
Regression test mirrors the Bugbot example.
Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
…— round 2 Bugbot follow-ups
Two further issues Bugbot caught after round 1:
1. `getTriggerGuardRootName` returned the first Identifier on
either side of a BinaryExpression. Since `undefined` is parsed
as Identifier (not Literal like `null`), the reversed
ordering `if (undefined !== pendingPayload)` returned
`"undefined"` and silently dropped the violation. Now skip
a curated SENTINEL_IDENTIFIER_NAMES set
(undefined / NaN / null) so the actual state Identifier
wins regardless of operand order.
2. The bare-truthy guard `if (destination) navigate(destination)`
produced TWO warnings on the same line: one from
`no-effect-event-handler` (its original §5 detector) and one
from the new `no-event-trigger-state`. The two messages overlap.
Solution: `no-effect-event-handler` now defers to
`no-event-trigger-state` when the dep is a useState value
(which is what `no-event-trigger-state` was built to handle).
For the canonical §5 case (prop-driven trigger like
`if (isOpen) document.body...`), `no-effect-event-handler`
still fires.
Two regression tests cover both fixes.
Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
…rigger-state would actually fire (Bugbot #155 round 3) Round 2 made no-effect-event-handler defer whenever the trigger guard's dep was a useState value. But no-event-trigger-state has narrower preconditions — single dep, handler-only writes, and specifically a callee in EVENT_TRIGGERED_SIDE_EFFECT_CALLEES (or EVENT_TRIGGERED_SIDE_EFFECT_MEMBER_METHODS for member calls). For a state-typed trigger guard whose consequent calls a custom function NOT in those allowlists (`if (trigger) customAction()`), the round-2 deference silently dropped the warning — neither rule fired. Tighten the deference: only skip when BOTH predicates hold — state-typed dep AND the consequent contains a recognized event- triggered side-effect callee. Otherwise no-effect-event-handler keeps owning the diagnostic, preserving the previous behavior for custom-callee shapes. Pulled the side-effect-callee detection into a reusable `consequentCallsTriggeredSideEffect` helper that mirrors what no-event-trigger-state's `findTriggeredSideEffectCalleeName` already does. The two rules now stay aligned via the same shared constants (EVENT_TRIGGERED_SIDE_EFFECT_CALLEES, EVENT_TRIGGERED_SIDE_EFFECT_MEMBER_METHODS). Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit b778bd8. Configure here.
…o findTriggeredSideEffectCalleeName (Bugbot #155 round 4) Round 3 introduced `consequentCallsTriggeredSideEffect` for the deference logic, which duplicated the AST walk + constant lookups from `findTriggeredSideEffectCalleeName` declared later in the file. Bugbot pointed out that the boolean function is exactly `findTriggeredSideEffectCalleeName(node) !== null`. Drop the duplicate. Reuse the existing helper directly. Forward reference works because the call site sits inside a closure (noEffectEventHandler.create()'s returned visitor) — at the time that visitor runs, all module-level functions are initialized. Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
no-event-trigger-state ruleno-event-trigger-state rule
cursor Bot
pushed a commit
that referenced
this pull request
May 8, 2026
…ndler defer Rebased onto main after #153 / #163 merged. Test file reconstructed by appending PR #155's describe block to main's version. The noEffectEventHandler rule now combines main's widened test predicate (uses getRootIdentifierName so MemberExpression guards like `if (product.isInCart)` match) with PR #155's deference logic (skip when noEventTriggerState would fire — state-typed dep + recognized side-effect callee). All 552 tests pass; lint, typecheck, format clean. Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
cursor Bot
pushed a commit
that referenced
this pull request
May 8, 2026
Rebased onto main after #154 / #155 / #156 merged. Clean reapply via patch — constants, oxlint-config, run-oxlint, fixtures, tests, and state-and-effects all applied without conflicts. Re-added USE_EFFECT_EVENT_MIN_MAJOR which got dropped in an earlier merge. All 555 tests pass. Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
aidenybai
added a commit
that referenced
this pull request
May 8, 2026
…eps, no-mirror-prop-effect, effect-needs-cleanup) (#157) Rebased onto main after #154 / #155 / #156 merged. Clean reapply via patch — all 9 files applied without conflicts. All 600 tests pass. Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

New rule (severity:
warn) flagging the §6 anti-pattern from React's You Might Not Need an Effect guide.What it catches
The state variable exists only to schedule an effect to run on click. The fix is to call
post(...)directly insidehandleSubmitand delete the state.Detector — four pre-conditions, all must hold
Chosen to keep real-world false positives near zero:
useEffectwith a single-identifier dep array, where the dep is auseStatebinding declared in this component.IfStatementguarding on that state with one of:if (X)!== null/!== undefined/!= null=== LiteralX.length(truthy)!X(negated)if's consequent contains aCallExpressionwhose callee is in a small allowlist:fetch,post,put,patch,del,ky,got,wretch,ofetch,navigate,navigateTo,showNotification,toast,alert,confirm,track,logEvent,logVisit,captureEventpost,put,patch,delete,push,replace,navigate,capture,track,logEvent— coversaxios.post,router.push,analytics.track,posthog.capture, etc.setX(...)call site in the component is inside a JSXon*handler (or a function bound to one) — i.e. the trigger is set only by user interactions.(4) is the strongest signal that the state exists only to schedule the effect, and is what distinguishes this rule from §5 (handled by the existing
no-effect-event-handler).What it does NOT flag
The article's legitimate analytics-on-mount effect:
is not flagged — empty deps, no trigger state, runs because the form was displayed.
State that's also written by other reactive logic (another effect, top-of-render adjustment) isn't flagged either — that's a different pattern.
Tests — 7 regression cases
post-trigger shapeaxios.postmember-call shapenavigate(...)compute(seed))useStatebinding)Plus a smoke test in
run-oxlint.test.tsagainst a fixture component (EventTriggerStateComponent).Reuse
Reuses existing
collectHandlerBindingNamesandisInsideEventHandlerhelpers already in the file (used byrerender-defer-reads-hook).Checks
488/488 tests passing locally. Lint, typecheck, format clean. Changeset included (minor bump).