feat(rules): add prefer-existing-hook-library#697
Open
NisargIO wants to merge 3 commits into
Open
Conversation
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>
commit: |
Contributor
|
No React Doctor issues found. 🎉 Reviewed by React Doctor for commit |
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>
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.
Why
Catches top-level custom hooks whose names match well-known hooks already shipped by
react-use/usehooks-ts. Hand-rolleduseDebounce,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:
After:
What changed
react-doctor/prefer-existing-hook-library(Maintainability,warn).FunctionDeclaration/VariableDeclaratorwhose name matches any of ~94 canonical hook names fromreact-use/usehooks-tsand whose body actually calls a React hook.react-useif neither is present.useXthat don't call any React hook, single-statement delegation wrappers (same-name OR renamed imports fromreact-use/usehooks-ts), pure re-exports, identifier re-bindings, and TypeScript ambient declarations.useLocation,useNavigation,useSearchParams,useRouter,useParams,usePathname,useHash), React-experimental / animation (useEvent,useEventCallback,useSpring), anduseDarkMode(semantically overloaded: most codebases ship a void DOM-effect hook, not the toggle-state hook fromusehooks-ts).test-noiseso test / fixture / story files auto-skip.aswrappers, default exports, ambient declarations, class methods, identifier re-bindings, renamed-import facades, and theuseDarkModeexclusion regression.Eval results
Corpus: 200 manifest entries scanned from
react-doctor-evals/repos.json(3209 cached locally out of 8423 total entries). Diagnostics extracted withreact-doctor --json --diff=false --warnings --no-score --blocking noneon the PR-built CLI against the cached clones at their pinned refs. The canonical RDEworker-poolrunner is currently broken onreact-doctor-evals/main(missingWorkerPool.layerprovision insrc/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
useDarkModefrom the map — see "FP-driven curation" below). Hook frequency:useDebounceuseUpdateEffectuseTimeoutuseLocalStorageuseFirstMountStateuseSessionStorageuseCopyToClipboarduseLatestuseFullscreenuseMediaQueryuseIntervaluseScrolluseWindowSizeuseEventListeneruseIntersectionObserverusePrevioususeBeforeUnloaduseResizeObserveruseOnClickOutsideuseQueueuseDocumentTitleuseOrientationuseScrollLockuseMediauseKeyPressRepos 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
useDarkModefix):twentyhq/twenty·packages/twenty-front/src/hooks/useFirstMountState.ts:3useFirstMountStatereact-use/useFirstMountState.twentyhq/twenty·packages/twenty-front/src/hooks/useCopyToClipboard.tsx:6useCopyToClipboardtwentyhq/twenty·packages/twenty-front/src/hooks/useUpdateEffect.ts:5useUpdateEffectuseFirstMountState.twentyhq/twenty·packages/twenty-website-new/src/lib/motion/use-media-query.ts:5useMediaQueryuseSyncExternalStorewith explicitserverFallback— strictly better than theuseEffect-based library versions for SSR. Flagging it would be a regression to a worse implementation.makeplane/plane·packages/hooks/src/use-local-storage.tsx:30useLocalStoragelocal-storage:${key}event for cross-tab sync instead of the standardstorageevent.Precision on the classified slice: 4 / 5 = 80%.
FP-driven curation
The first eval pass flagged
tldraw/tldraw/packages/editor/src/lib/hooks/useDarkMode.tsfor auseDarkModehook. On inspection it's a void DOM-side-effect hook that toggles theme classes viauseEffect— incompatible withusehooks-ts/useDarkModewhich returns{ isDarkMode, toggle, enable, disable, set }. Same name, different API: classic name-only collision. Pushedc571bcdeto dropuseDarkModefrom 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.
useMediaQueryviauseSyncExternalStore). A v2 mitigation — skip when the only React hook called isuseSyncExternalStore— 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 theuseDarkModeexclusion)pnpm --filter oxlint-plugin-react-doctor typecheckpnpm exec prettier --checkon touched filespnpm exec vp linton touched filestldraw/editorafter theuseDarkModemap removal produces 0prefer-existing-hook-librarydiagnostics