React Hooks Lint Rules
A verification checklist for eslint-plugin-react-hooks in Expo React Native apps - catching illegal hook calls, stale closures in async mobile code, and missing cleanup before they ship to backgrounded devices.
Search across all documentation pages
A verification checklist for eslint-plugin-react-hooks in Expo React Native apps - catching illegal hook calls, stale closures in async mobile code, and missing cleanup before they ship to backgrounded devices.
eslint-config-expo registers the hooks plugin before adding overrides.fetch tutorials suggest.eslint-disable-next-line react-hooks/exhaustive-deps as a documented exception - note why the dep is stable or why stale data is acceptable.Confirm eslint-plugin-react-hooks is active: eslint-config-expo bundles the plugin in flat config - run npx eslint --print-config app/index.tsx and verify react-hooks/ rules appear.
eslint-plugin-react-hooks and extend its recommended preset explicitly.react-hooks/rules-of-hooks severity: Keep at error - illegal hook order breaks React in production and cannot be downgraded to warn without hiding ship-stoppers.
error fails expo lint and GitHub Actions the same way as TypeScript errors.off for this rule lets conditional hooks merge silently.react-hooks/exhaustive-deps severity: Default to warn in active codebases; promote to error only after the team has a written suppression policy.
--max-warnings 0 when you want zero warnings without fighting stable-ref false positives on day one.error to src/features/** once greenfield folders are clean.Flat config file targeting: Ensure app/, src/, and components/ are inside ESLint files globs - hooks in route files are linted the same as shared hooks.
packages/ui - stale closures in a design-system hook affect every screen.app/ because Expo Router filenames confuse the linter.Editor integration: Enable ESLint in the IDE with flat-config support so exhaustive-deps surfaces while typing effects, not only in CI.
expo lint via lint-staged on changed .tsx files.No hooks inside renderItem: FlatList / SectionList renderItem is a callback, not a component - extract TodoRow and call hooks at the top of that component.
rules-of-hooks flags hook calls inside nested functions.renderItem={({ item }) => <TodoRow item={item} />}.No hooks after early returns: Guard clauses above useState / useEffect violate hook order when the guard toggles between renders.
if (!fontsLoaded) return null placed before hooks in a screen.Prefix custom hooks with use: getFilters() can call useState illegally; useFilters() triggers rules-of-hooks on the custom hook body.
use-app-state.ts exporting useAppState.renderHook from Testing Library still requires a proper use* hook.No hooks inside useMemo / useCallback factories: The factory runs during render; hooks belong at component top level only.
useMemo(() => { useEffect(...) }).useEffect with cleanup, or a dedicated custom hook.No hooks in Reanimated worklets or non-React callbacks: Worklets and native module callbacks are outside React's render cycle - use shared values and runOnJS instead.
// eslint-disable is not the answer.AppState listeners: Effects that subscribe to AppState.addEventListener must list every closed-over value used inside the handler, or read fresh values from refs.
userId from render when app returns from background after logout.userId in deps, or const userIdRef = useRef(userId) synced in a separate effect.useFocusEffect from Expo Router / React Navigation: The callback identity matters - wrap work in useCallback with correct deps or eslint flags the effect wrapper.
route.params.id omitted from deps; wrong record flashes after deep link.[route.params.id] in useCallback deps; return cleanup to cancel in-flight fetch.NetInfo / connectivity subscriptions: NetInfo.addEventListener callbacks that enqueue mutations must see current auth and queue state.
accessToken or read from a ref updated on refresh.Keyboard and dimension listeners: Keyboard.addListener and Dimensions.addEventListener effects need cleanup and deps for values used when firing.
keyboardVerticalOffset computed once; lint-clean but wrong after rotation.layout state or re-read Dimensions.get inside the handler.Async effects and AbortController: useEffect that calls async function load() must abort on cleanup and list deps that change the request.
const ac = new AbortController(); … return () => ac.abort(); plus full dep array.Timers (setInterval, setTimeout): Effects scheduling timers must clear them in cleanup and include delay-driving deps.
useFocusEffect cleanup was forgotten.useEffect return and focus-effect cleanup.Stable refs vs missing deps: dispatch from useReducer, setState from useState, and refs are stable - omitting them is fine; omitting props and derived state is not.
dispatch - safe to leave; do not disable the rule for it.filter when effect posts filter to analytics - add filter.TanStack Query and context: Include data, isFetching, and query-key inputs in deps when effects react to them; queryClient from useQueryClient() is stable.
useEffect on data but missing dataUpdatedAt when only freshness matters.One-line disable policy: eslint-disable-next-line react-hooks/exhaustive-deps requires an inline comment explaining stability or intentional staleness.
// deps intentionally empty - mount-only analytics ping.Never disable rules-of-hooks: If the rule fires, refactor - conditional hooks are never a lint false positive.
Custom hooks export a coherent dep story: Hooks wrapping AppState / NetInfo should document which values callers must pass in so caller effects stay lint-clean.
useOnAppForeground(onForeground, deps) internally mirrors useEffect deps.Functional updates for rapid events: setCount(c => c + 1) removes count from handler deps - prefer over disabling lint on press handlers.
setState in the same gesture - functional form stays correct.useCallback / useMemo dependency lint: Nested hooks lint their own factories - if useCallback omits a closed-over prop, the bug is in the callback deps, not exhaustive-deps on the effect alone.
useEffect(() => { doWork(cb) }, [cb]) where cb recreates every render anyway.cb with correct useCallback deps or inline the work in the effect.CI enforcement: Add expo lint (or eslint .) to PR checks with --max-warnings 0 when policy matures.
rules-of-hooks as error only; flip exhaustive-deps to error per package.Yes - the Expo flat config preset registers eslint-plugin-react-hooks. Confirm with npx eslint --print-config on a screen file before adding a duplicate plugin install.
Start at warn so stable-ref false positives do not block velocity. Move to error (or --max-warnings 0) once the team documents acceptable one-line suppressions. Keep rules-of-hooks at error always.
exhaustive-deps is static analysis - it cannot verify your AppState handler reads fresh auth state. Audit Tier 3 listeners manually and test background → foreground on a physical device.
You can, but the effect re-runs every render if the callback identity changes. Wrap the body in useCallback with the same deps you would put on a useEffect, or accept redundant refetches.
Yes - useReducer dispatch is stable for the component lifetime. The same applies to useState setters. Do not disable the rule solely to silence dispatch; add real missing values instead.
Extract a row component: function Row({ item }) { const theme = useTheme(); … }. Pass renderItem={({ item }) => <Row item={item} />}. Hooks then run in a real component.
Usually no - useQueryClient() returns a stable client. Include query results and variables that change fetch behavior (id, filter), not the client singleton.
When you can state in one line why the dependency is stable (mount-only, ref-backed, or intentional empty deps). Never blanket-disable for a whole file or directory.
Batching and concurrent rendering make stale closures more visible, not less. Hook rules and exhaustive-deps semantics are unchanged - still enforce them on SDK 57.
Apply the same react-hooks rules block to packages/** TypeScript sources. A stale hook in packages/ui ships to every app in the workspace.
If your SDK ships an official stable API, follow its lint guidance. Until then, prefer refs for stable handlers inside effects rather than turning off exhaustive-deps globally.
No - useMemo has its own dependency array. Effects that read memoized values still need those values (or their deps) listed on the effect.
Use renderHook with @testing-library/react-native, unmount the hook, and assert listeners are removed (mock AppState.addEventListener). Strict Mode double-mount in dev helps surface missing cleanup during manual runs.
Avoid folder-wide disables for app/. Route screens are where useFocusEffect and param-driven effects concentrate - they need lint most, not least.
Wrap AppState, NetInfo, and Keyboard in use* hooks with explicit parameters for closed-over values. See Custom Hooks for UI Logic.
eslint-config-expo, flat config, and first-pass rule setexpo lint and --max-warnings 0Stack versions: This page was written for React 19.2.3, React Native 0.86.0, and Expo SDK 57 (
expo~57.0.4).
Reviewed by Chris St. John·Last updated Jul 19, 2026