Skip to content

feat: add no-event-trigger-state rule#155

Draft
aidenybai wants to merge 3 commits intomainfrom
cursor/no-event-trigger-state-7144
Draft

feat: add no-event-trigger-state rule#155
aidenybai wants to merge 3 commits intomainfrom
cursor/no-event-trigger-state-7144

Conversation

@aidenybai
Copy link
Copy Markdown
Member

New rule (severity: warn) flagging the §6 anti-pattern from React's You Might Not Need an Effect guide.

What it catches

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(...) directly inside handleSubmit and delete the state.

Detector — four pre-conditions, all 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 if (X)
    • !== null / !== undefined / != null
    • === Literal
    • X.length (truthy)
    • !X (negated)
  3. the if's consequent contains a CallExpression whose callee is in a small allowlist:
    • direct names: fetch, post, put, patch, del, ky, got, wretch, ofetch, navigate, navigateTo, showNotification, toast, alert, confirm, track, logEvent, logVisit, captureEvent
    • member-call methods: post, put, patch, delete, push, replace, navigate, capture, track, logEvent — covers axios.post, router.push, analytics.track, posthog.capture, etc.
  4. every setX(...) 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).

What it does NOT flag

The article's legitimate analytics-on-mount effect:

useEffect(() => post("/analytics/event", { eventName: "visit_form" }), []);

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

  • ✅ flags canonical post-trigger shape
  • ✅ flags axios.post member-call shape
  • ✅ flags bare-truthy guard with navigate(...)
  • ❌ does NOT flag analytics-on-mount with empty deps
  • ❌ does NOT flag when state is also written outside handlers
  • ❌ does NOT flag when consequent has no recognized side-effect (compute(seed))
  • ❌ does NOT flag when the dep is a prop (no useState binding)

Plus a smoke test in run-oxlint.test.ts against a fixture component (EventTriggerStateComponent).

Reuse

Reuses existing collectHandlerBindingNames and isInsideEventHandler helpers already in the file (used by rerender-defer-reads-hook).

Checks

488/488 tests passing locally. Lint, typecheck, format clean. Changeset included (minor bump).

Open in Web Open in Cursor 

cursoragent and others added 2 commits May 7, 2026 11:18
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>
@vercel
Copy link
Copy Markdown

vercel Bot commented May 7, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
react-doctor-website Ready Ready Preview, Comment May 7, 2026 11:48am

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants