eslint-plugin-hook-order
v1.0.0
Published
Pragmatic ESLint rule for React hook ordering: configurable anchor hooks first, effects last, with a smart exception for dependent state — low false positives, no forced total order.
Maintainers
Readme
eslint-plugin-hook-order
A pragmatic ESLint rule for React hook ordering. It keeps components scannable without forcing a rigid, whole-list order that fights you at every turn.
Most hook-ordering plugins make you declare a total order of every hook and
then flag anything that deviates — so useMemo before useCallback becomes an
"error" that means nothing. This plugin constrains only what actually helps a
reader, and is built to be false-positive-free:
- a small, configurable set of anchor hooks must keep their relative order
and sit at the top (
useState→useRefby default); - effect hooks are the last group — nothing runs after a
useEffect; - everything in between is free.
It is report-only and never autofixes: reordering hook statements can change behaviour when one hook feeds another, so the fix is always a human decision.
Install
npm install --save-dev eslint-plugin-hook-orderRequires ESLint >=8.57. Works on ESLint 8 (legacy config) and ESLint 9 (flat
config). For TypeScript / TSX, use @typescript-eslint/parser.
Usage
Flat config (ESLint 9, eslint.config.js)
import hookOrder from 'eslint-plugin-hook-order';
export default [
hookOrder.configs['flat/recommended'],
// …or wire the rule yourself:
{
plugins: { 'hook-order': hookOrder },
rules: {
'hook-order/order': 'warn',
},
},
];Legacy config (ESLint 8, .eslintrc.js)
module.exports = {
plugins: ['hook-order'],
extends: ['plugin:hook-order/recommended'],
// …or:
rules: {
'hook-order/order': 'warn',
},
};What the rule enforces
Inside React components (PascalCase) and custom hooks (useXxx), looking only
at the top-level statements of the function body:
- Anchor order — the anchor hooks that are present keep their configured
relative order (default
useStatebeforeuseRef). - Anchors on top — no other hook and no local function declaration
(
const onClose = () => {}) may appear before an anchor. - Effects last — no non-effect hook may appear after
useEffect/useLayoutEffect/useInsertionEffect.
// ✓ clean
export const UserCard = ({ id }: Props) => {
const [open, setOpen] = useState(false); // anchors …
const ref = useRef<HTMLDivElement>(null); // … in order, on top
const { data } = useUser(id); // other hooks — any order
const label = useMemo(() => fmt(data), [data]);
const onToggle = () => setOpen((v) => !v); // handlers after hooks
useEffect(() => log(id), [id]); // effect last
return <div ref={ref}>…</div>;
};// ✗ flagged
export const UserCard = ({ id }: Props) => {
const { data } = useUser(id); // ✗ anchorsTop: other hook before anchor
const ref = useRef(null);
const [open, setOpen] = useState(false); // ✗ anchorOrder: useState after useRef
useEffect(() => log(id), [id]);
const label = useMemo(() => data, [data]); // ✗ effectNotLast: hook after useEffect
return null;
};Options
'hook-order/order': ['warn', {
anchors: ['useState', 'useRef'],
effectHooks: ['useEffect', 'useLayoutEffect', 'useInsertionEffect'],
allowDependentAnchor: true,
}]| Option | Type | Default | Description |
| --- | --- | --- | --- |
| anchors | string[] | ['useState', 'useRef'] | Hooks whose relative order is enforced and which must sit at the top. Order in the array = required order in code. |
| effectHooks | string[] | ['useEffect', 'useLayoutEffect', 'useInsertionEffect'] | Hooks that form the final group; nothing may follow them. |
| allowDependentAnchor | boolean | true | Allow a useState/useRef whose initial value depends on an earlier variable to sit lower (see below). |
Example: a project with routing + i18n anchors
'hook-order/order': ['warn', {
anchors: ['useState', 'useRef', 'useRouter', 'useTranslation'],
}]Now useRouter must come after useRef and before your other hooks, and
useTranslation after useRouter — the four read top-to-bottom in every file.
The dependent-anchor exception
Sometimes a useState/useRef legitimately can't be at the top because its
initial value comes from an earlier hook:
const disclosure = useDisclosure();
const [open, setOpen] = useState(disclosure.defaultOpen); // depends on `disclosure`Hoisting that useState would break the code. The rule detects the data
dependency and does not flag it. Set allowDependentAnchor: false if you'd
rather forbid the pattern outright and require an explicit
// eslint-disable-next-line.
Design notes (what it deliberately does not do)
- No forced total order. Constraining
useMemovsuseCallbackvs custom hooks produces churn without improving readability, so the rule leaves the middle free. This is the main difference fromgroups-style plugins. - "Effects last" is a group rule, not "the line before
return." Early and branching returns make "physically last" ill-defined; "no hook after an effect" is the crisp, false-positive-free version. - No autofix. Statement reordering can change runtime behaviour. The rule reports; you move the line.
Handled edge cases
- Namespaced calls —
React.useState,React.useEffect. - TSX wrappers —
useFoo() as T,useFoo()!,useFoo() satisfies T,useFoo?.(). - HOC-wrapped components —
memo(() => …),forwardRef(() => …), and nesting. - Multi-declarator statements —
const a = useState(), b = useRef();. - Wrapper hooks ending in
return useMemo(…).
Hooks nested in conditionals/loops are intentionally out of scope — that's
already covered by react-hooks/rules-of-hooks.
Development
npm install
npm test # ESLint RuleTester suiteLicense
MIT © Ivan Holovko
