Skip to content

feat(rules): add prefer-existing-hook-library#697

Open
NisargIO wants to merge 3 commits into
mainfrom
feat/rule-prefer-existing-hook-library
Open

feat(rules): add prefer-existing-hook-library#697
NisargIO wants to merge 3 commits into
mainfrom
feat/rule-prefer-existing-hook-library

Conversation

@NisargIO

@NisargIO NisargIO commented Jun 5, 2026

Copy link
Copy Markdown
Member

Why

Catches top-level custom hooks whose names match well-known hooks already shipped by react-use / usehooks-ts. Hand-rolled useDebounce, useLocalStorage, useOnClickOutside, useEventListener, … almost always miss at least one of: SSR safety during hydration, cleanup on unmount, identity-unstable callbacks producing stale closures, Strict-Mode double-fire semantics, or cross-tab storage sync — bugs the battle-tested library versions already handle. AI agents reimplementing these hooks ship the same bugs at scale.

Before:

import { useEffect, useState } from "react";

function useDebounce(value, delay) {
  const [debounced, setDebounced] = useState(value);
  useEffect(() => {
    const handle = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(handle);
  }, [value, delay]);
  return debounced;
}

After:

import { useDebounce } from "react-use";

const debounced = useDebounce(value, delay);

What changed

  • Added react-doctor/prefer-existing-hook-library (Maintainability, warn).
  • Detects module-scope FunctionDeclaration / VariableDeclarator whose name matches any of ~94 canonical hook names from react-use / usehooks-ts and whose body actually calls a React hook.
  • Reports the exact hook name and dynamically lists the library/libraries that already ship it, plus a fallback recommendation to install react-use if neither is present.
  • Allows: hooks defined inside another component / hook, utilities named useX that don't call any React hook, single-statement delegation wrappers (same-name OR renamed imports from react-use / usehooks-ts), pure re-exports, identifier re-bindings, and TypeScript ambient declarations.
  • Curated hook map excludes ambiguous names: routing (useLocation, useNavigation, useSearchParams, useRouter, useParams, usePathname, useHash), React-experimental / animation (useEvent, useEventCallback, useSpring), and useDarkMode (semantically overloaded: most codebases ship a void DOM-effect hook, not the toggle-state hook from usehooks-ts).
  • Tagged test-noise so test / fixture / story files auto-skip.
  • 25 adversarial tests cover arrow-expression bodies, parenthesized hook-call bodies, TS as wrappers, default exports, ambient declarations, class methods, identifier re-bindings, renamed-import facades, and the useDarkMode exclusion regression.

Eval results

Corpus: 200 manifest entries scanned from react-doctor-evals/repos.json (3209 cached locally out of 8423 total entries). Diagnostics extracted with react-doctor --json --diff=false --warnings --no-score --blocking none on the PR-built CLI against the cached clones at their pinned refs. The canonical RDE worker-pool runner is currently broken on react-doctor-evals/main (missing WorkerPool.layer provision in src/Commands.ts) so this used a direct invocation against the same cache the worker pool would have used.

Result: 38 hits across 13 repos (after removing useDarkMode from the map — see "FP-driven curation" below). Hook frequency:

Hook Hits Hook Hits Hook Hits
useDebounce 7 useUpdateEffect 1 useTimeout 1
useLocalStorage 3 useFirstMountState 1 useSessionStorage 1
useCopyToClipboard 2 useLatest 1 useFullscreen 1
useMediaQuery 2 useInterval 1 useScroll 1
useWindowSize 2 useEventListener 1 useIntersectionObserver 1
usePrevious 2 useBeforeUnload 1 useResizeObserver 1
useOnClickOutside 2 useQueue 1 useDocumentTitle 1
useOrientation 1 useScrollLock 1
useMedia 1 useKeyPress 1

Repos flagged: twentyhq/twenty (4 hits), makeplane/plane, PostHog/posthog (7), supabase/supabase (2), payloadcms/payload (7), getsentry/sentry (9), dubinc/dub (6).

Hand-classified sample (50-entry subset, after the useDarkMode fix):

Repo · file Hook Verdict Notes
twentyhq/twenty · packages/twenty-front/src/hooks/useFirstMountState.ts:3 useFirstMountState TP Textbook line-for-line reimplementation of react-use/useFirstMountState.
twentyhq/twenty · packages/twenty-front/src/hooks/useCopyToClipboard.tsx:6 useCopyToClipboard TP (specialized) Hand-rolled clipboard hook with secure-context check + snackbar integration. The snackbar coupling is twenty-specific; the underlying clipboard handling is exactly what library versions do.
twentyhq/twenty · packages/twenty-front/src/hooks/useUpdateEffect.ts:5 useUpdateEffect TP Textbook reimplementation; depends on the also-reimplemented useFirstMountState.
twentyhq/twenty · packages/twenty-website-new/src/lib/motion/use-media-query.ts:5 useMediaQuery FP (better impl) Uses useSyncExternalStore with explicit serverFallback — strictly better than the useEffect-based library versions for SSR. Flagging it would be a regression to a worse implementation.
makeplane/plane · packages/hooks/src/use-local-storage.tsx:30 useLocalStorage TP Hand-rolled; uses a custom local-storage:${key} event for cross-tab sync instead of the standard storage event.

Precision on the classified slice: 4 / 5 = 80%.

FP-driven curation

The first eval pass flagged tldraw/tldraw/packages/editor/src/lib/hooks/useDarkMode.ts for a useDarkMode hook. On inspection it's a void DOM-side-effect hook that toggles theme classes via useEffect — incompatible with usehooks-ts/useDarkMode which returns { isDarkMode, toggle, enable, disable, set }. Same name, different API: classic name-only collision. Pushed c571bcde to drop useDarkMode from the map (and added a regression test + HACK comment so it doesn't sneak back).

The remaining recognizable FP class is better-than-library implementations (e.g. useMediaQuery via useSyncExternalStore). A v2 mitigation — skip when the only React hook called is useSyncExternalStore — would push precision past 90% without losing TPs. Not in this PR.

Suppression

Rule is warn-only and respects // react-doctor-disable-next-line prefer-existing-hook-library, so the remaining FP class is one inline directive away from quiet for projects that hit it.

Test plan

  • pnpm exec vp test run packages/oxlint-plugin-react-doctor/src/plugin/rules/architecture/prefer-existing-hook-library.test.ts — 25 / 25 pass (added 1 regression test for the useDarkMode exclusion)
  • pnpm --filter oxlint-plugin-react-doctor typecheck
  • pnpm exec prettier --check on touched files
  • pnpm exec vp lint on touched files
  • Eval against 200 cached real-world entries from the RDE corpus (see table above)
  • Re-verified post-fix: re-scanning tldraw/editor after the useDarkMode map removal produces 0 prefer-existing-hook-library diagnostics

Catches top-level custom hooks whose names match a hook already shipped
by react-use or usehooks-ts (useDebounce, useLocalStorage,
useOnClickOutside, useToggle, usePrevious, useEventListener, useInterval,
useMount, useUpdateEffect, ... ~95 names). Hand-rolled versions commonly
miss SSR safety, cleanup races, stale closures on identity-unstable
callbacks, and Strict-Mode double-fire semantics that the library hooks
already handle.

Detection is name-based + scope-aware: only module-level
FunctionDeclaration / VariableDeclarator whose body actually contains
React hook calls are flagged. Hooks defined inside another component
or hook, utilities that happen to start with "use" but never call a
hook, pure re-exports, and single-statement delegation wrappers
(including renamed-import facades like `import { useDebounce as
upstream } from "react-use"; export const useDebounce = (v) =>
upstream(v, 500)`) are skipped. Tagged "test-noise" so fixture / story
/ test files auto-skip.

Ambiguous names that clash with React / routing / animation libraries
(useLocation, useEvent, useEventCallback, useSearchParams,
useNavigation, useRouter, useSpring, useHash) are intentionally
excluded from the match list so the rule stays high-precision.

24 adversarial tests cover arrow-expression bodies, parenthesized
hook-call bodies, TS `as` wrappers, default exports, ambient
declarations, class methods, identifier re-bindings, and renamed-import
facades.

Co-authored-by: Cursor <cursoragent@cursor.com>
@pkg-pr-new

pkg-pr-new Bot commented Jun 5, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/eslint-plugin-react-doctor@697
npm i https://pkg.pr.new/oxlint-plugin-react-doctor@697
npm i https://pkg.pr.new/react-doctor@697

commit: 02223a2

@github-actions

github-actions Bot commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

No React Doctor issues found. 🎉

Reviewed by React Doctor for commit 02223a2.

NisargIO and others added 2 commits June 5, 2026 17:04
The 50-entry RDE eval (tldraw/editor) confirmed what the name suggests:
most codebases that ship a `useDarkMode` hook use it as a void
DOM-side-effect that applies theme classes, not as a state toggle. Same
name as `usehooks-ts/useDarkMode` but a wholly incompatible API
(`useDarkMode(): void` vs `useDarkMode(): { isDarkMode, toggle, ... }`),
so the match was a false positive every time it fired.

Removed from HOOK_LIBRARY_MAP and pinned in place with a regression test
+ HACK comment in the exclusions block, alongside the
`useLocation` / `useEvent` / `useEventCallback` exclusions. Users who
actually want the toggle version typically import it directly from
usehooks-ts anyway.

Co-authored-by: Cursor <cursoragent@cursor.com>
@NisargIO NisargIO requested a review from rayhanadev June 6, 2026 01:00
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.

1 participant