Skip to content

feat: add prefer-use-sync-external-store rule#154

Draft
aidenybai wants to merge 3 commits intomainfrom
cursor/prefer-use-sync-external-store-7144
Draft

feat: add prefer-use-sync-external-store rule#154
aidenybai wants to merge 3 commits intomainfrom
cursor/prefer-use-sync-external-store-7144

Conversation

@aidenybai
Copy link
Copy Markdown
Member

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

What it catches

const [snapshot, setSnapshot] = useState(getSnapshot());
useEffect(() => {
  const unsubscribe = store.subscribe(() => setSnapshot(getSnapshot()));
  return unsubscribe;
}, []);

The hand-rolled subscribe pattern reimplements useSyncExternalStore in user space — incorrectly. The hook handles tearing during concurrent renders and SSR snapshots; the manual pattern doesn't. The rule suggests:

const snapshot = useSyncExternalStore(store.subscribe, getSnapshot);

Detector — four-vertex AST match

Real-world false positives are essentially impossible because all four conditions must coincide:

  1. useEffect with empty deps []
  2. body declares const u = X.subscribe(handler) OR directly invokes a subscription method X.addEventListener(...)
  3. cleanup is a return that returns the unsubscribe binding directly OR returns a closure that unsubscribes
  4. handler is a single setY(<getter>) whose <getter> is structurally equal to the matching useState's initializer

Subscription method names recognized: subscribe, addEventListener, addListener, on, watch, listen, sub — covers zustand, Redux vanilla, Valtio, Effector, Jotai store API, RxJS observables, and the browser addEventListener shape (matchMedia, navigator.onLine).

The detector also resolves Identifier-passed handlers (the dominant real-world shape):

const onChange = () => setIsOnline(navigator.onLine);   // ← walked back to the inline arrow
window.addEventListener("online", onChange);
return () => window.removeEventListener("online", onChange);

Library safety

Modern store hooks (useAtomValue from Jotai, useStore from zustand v4+, useSelector from react-redux v8+) already use useSyncExternalStore under the hood and are not flagged — the rule only catches code that bypasses the library's hook to roll its own subscription.

Tests — 7 regression cases

  • ✅ flags canonical store.subscribe shape (article §11)
  • ✅ flags browser-API addEventListener shape (navigator.onLine)
  • ✅ flags lazy-initializer variant useState(() => getSnapshot())
  • ❌ does NOT flag legitimate chat-connection effect with non-empty deps
  • ❌ does NOT flag subscription without paired useState (audit-only)
  • ❌ does NOT flag when the setter argument doesn't match the useState initializer
  • ❌ does NOT flag when there's no cleanup

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

Refactor included

  • New SUBSCRIPTION_METHOD_NAMES and UNSUBSCRIPTION_METHOD_NAMES sets in constants.ts (replaces the local 4-element set in advancedEventHandlerRefs — same shape, broader allowlist).
  • New areExpressionsStructurallyEqual helper in helpers.ts for the four-vertex match.

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:10
New rule (severity: warn) flagging the §11 anti-pattern from React's
'You Might Not Need an Effect' guide:

  const [snapshot, setSnapshot] = useState(getSnapshot());
  useEffect(() => {
    const unsub = store.subscribe(() => setSnapshot(getSnapshot()));
    return unsub;
  }, []);

The hand-rolled subscribe pattern reimplements useSyncExternalStore in
user space — incorrectly. The hook handles tearing during concurrent
renders and SSR snapshots; the manual pattern doesn't. The rule
suggests:

  const snapshot = useSyncExternalStore(store.subscribe, getSnapshot);

Detector requires a four-vertex AST match before firing:
  (1) useEffect with empty deps              `[]`
  (2) body declares `const u = X.subscribe(handler)` OR
      directly invokes a subscription method X.addEventListener(...)
  (3) cleanup is a `return` that returns the unsubscribe binding
      directly OR returns a closure that unsubscribes
  (4) handler is a single `setY(<getter>)` whose <getter> is
      structurally equal to the matching useState's initializer

Subscription method names recognized: subscribe, addEventListener,
addListener, on, watch, listen, sub. Covers zustand, Redux vanilla,
Valtio, Effector, Jotai store API, RxJS observables, and the browser
`addEventListener` shape (matchMedia, navigator.onLine).

Resolves Identifier-passed handlers like
  const onChange = () => setIsOnline(navigator.onLine);
  window.addEventListener('online', onChange);
by walking earlier const declarations in the same effect body.

Adds:
  - SUBSCRIPTION_METHOD_NAMES + UNSUBSCRIPTION_METHOD_NAMES to constants
    (replaces the local 4-element set in advancedEventHandlerRefs;
    same shape, broader allowlist).
  - areExpressionsStructurallyEqual helper for the four-vertex
    structural match.

Tests: 7 regression cases — canonical store-subscribe shape, browser
addEventListener shape, lazy-initializer variant, and four
no-flag cases (legitimate chat connection with non-empty deps,
subscribe-as-side-effect with no useState pair, mismatched setter
arg, missing cleanup).

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