You know that tiny, nagging moment when your React component’s state just won't behave? You call useState or useEffect, and suddenly your UI goes haywire. Maybe state updates vanish silently, effects run unexpectedly, or your component throws a cryptic error you don’t understand.
I’ve been there. I once spent a solid afternoon debugging a bug that boiled down to breaking the React Hook Rules , the strict conventions React enforces on how and where you call hooks.
The moment the bug hit
I was adding a new feature in a complex component. To keep things neat, I put a hook call inside a conditional:
if (someFlag) {
const [count, setCount] = useState(0);
}
React didn’t complain immediately. But later, state updated weirdly: sometimes count was undefined, or effects ran twice, or worse, the entire subtree behaved strangely.
No errors, no warnings. Just subtle, unpredictable bugs.
Why React’s Hook Rules exist
Hooks are a special kind of function call. React relies on the order and number of hooks called every render to manage state and effects correctly. The rules are simple but strict:
- Only call hooks at the top level of your React function components or custom hooks.
- Don’t call hooks inside loops, conditions, or nested functions.
Why? Because React tracks hook calls by their call order. Each render, React builds an internal list of hooks in order, and assigns state and effects by position.
If you break these rules, the hook calls don’t line up between renders. React gets confused about which state belongs to which hook call.
What happens under the hood when you break the rules
React uses a linked list or array internally to track hooks for each component. Each hook corresponds to a slot in this list, storing its state, effect, or ref.
Imagine your component’s hook calls as a sequence:
useState(0)
useEffect(...)
useContext(...)
On every render, React expects these hooks in the same order. When you call hooks conditionally, the order changes:
- On first render, the condition is true, so hooks 1, 2, and 3 exist.
- On second render, the condition is false, so maybe only hooks 1 and 3 get called.
React tries to update hook slots 1, 2, and 3, but the sequence no longer matches. It updates the wrong state or effects, leading to bugs like:
- State values from one hook assigned to another
- Effects running multiple times or not at all
- Errors like "Rendered fewer hooks than expected"
Concrete example: conditional hooks causing state mixup
Consider this snippet:
function Counter({ enabled }) {
if (enabled) {
const [count, setCount] = React.useState(0);
React.useEffect(() => {
console.log('Count changed', count);
}, [count]);
return <div>{count}</div>;
}
return <div>Disabled</div>;
}
If enabled flips between true and false, the number of hooks calls changes between renders:
- When
enabled is true, there are two hooks: useState and useEffect.
- When
enabled is false, no hooks get called.
React expects the same number of hooks every render. The mismatch causes React to assign internal hook states incorrectly. The count state might hold garbage or cause errors.
How React’s lint rules and runtime warnings help
React provides an ESLint plugin that catches most hook rule violations statically. It warns when hooks appear inside loops, conditions, or nested functions.
But linting can’t catch everything. Dynamic conditions or complex code paths might still break rules at runtime.
React also throws runtime warnings when the number of hooks calls changes between renders, but these often appear only after the bug has already caused strange behavior.
Tradeoffs in strict enforcement
React chooses to rely on conventions rather than runtime enforcement for performance reasons. Hook state tracking needs to be fast; adding heavy runtime checks on every render would slow down apps.
So React trusts you to follow the rules, with lint rules as your safety net.
How to avoid the bugs
-
Always call hooks unconditionally at the top level of your component or custom hook.
-
If you need conditional logic, put it inside the hook's callback or state, not around the hook itself:
const [count, setCount] = useState(0);
React.useEffect(() => {
if (enabled) {
// do something
}
}, [enabled, count]);
What I learned from debugging hook rule violations
The hardest bugs caused by broken hook rules are subtle: no clear error, just silent state corruption or lost updates. It’s like your component is haunted.
Understanding React’s hook call ordering and internal tracking helped me recognize why conditional hooks are a no-go.
Next time your state or effects misbehave unpredictably, double-check your hooks. Most likely, you’re breaking the call order React depends on.
Following the rules might feel restrictive at first, but it’s what keeps your component’s state consistent and your app’s UI predictable.
If you haven’t already, set up the React hooks ESLint plugin today. It will save you hours chasing ghosts caused by broken hook rules.