The same subscription logic appeared in four components: connect on mount, reconnect when the id changes, disconnect on unmount, and set a piece of state when a message arrives. Four copies, three of which had drifted, and no way to share any of it — because the behaviour was tied to the lifecycle and the lifecycle belongs to a class.
The symptom
class OrderStatus extends React.Component {
componentDidMount() {
this.sub = subscribe(this.props.orderId, this.onMessage);
}
componentDidUpdate(prev) {
if (prev.orderId !== this.props.orderId) {
this.sub.unsubscribe();
this.sub = subscribe(this.props.orderId, this.onMessage);
}
}
componentWillUnmount() {
this.sub.unsubscribe();
}
}
One concern spread across three methods, and the connect logic written twice within the same class. Two of the four copies had lost the componentDidUpdate branch entirely, so they subscribed once and then showed the first order’s status forever — a bug nobody had reported because it only appears when a user navigates between two orders without a page load.
Why it happens
The lifecycle methods are organised by when they run rather than by what they are about. Anything that has a setup, a change and a teardown is necessarily split across three of them, and anything with two such concerns has both interleaved in all three.
The existing answers to sharing that logic were higher-order components and render props, and both work by adding a layer to the tree. Three of them nested produce a component whose props come from somewhere four levels up and a devtools panel that is mostly wrappers — the “wrapper hell” the hooks announcement led with, which is a real problem and not the main one.
The fix
useState and useEffect
function OrderStatus({ orderId }) {
const [status, setStatus] = useState(null);
useEffect(() => {
const sub = subscribe(orderId, setStatus);
return () => sub.unsubscribe();
}, [orderId]);
return <Badge status={status} />;
}
Three lifecycle methods become one effect with a cleanup, and the change case is handled by the same code as the mount case — which is why the missing componentDidUpdate branch cannot happen. The effect runs after the first render and after any render where a dependency changed, and the cleanup runs before each of those and on unmount.
The setter from useState replaces rather than merges, which is a silent behavioural difference from this.setState and the first thing that catches anyone translating a class by hand. Splitting related-but-independent values into separate calls is almost always better than one object, and it is what the API is shaped for.
The dependency array, which is not a watch list
This is the part that is genuinely hard, and reading it as “re-run when these change” is close enough to be useful and wrong in a way that produces stale data.
// it is a declaration of everything the effect closes over.
// an empty array claims the effect uses nothing that changes.
useEffect(() => {
const t = setInterval(() => console.log(count), 1000);
return () => clearInterval(t);
}, []);
// count is captured from the FIRST render. it is 0 forever.
// the functional setter reads the current value with no dependency
setCount(c => c + 1);
// or, for a value that is only read, a ref
const latest = useRef(count);
useEffect(() => { latest.current = count; });
A closure is a snapshot, and every render creates new ones. In a class, this.state is always current because this is a mutable object that survives; in a function component there is no such object, and that difference is the source of nearly every hooks bug worth the name.
Removing a dependency to stop an effect looping is treating the symptom. The cause is almost always a value being recreated upstream — an inline object, an unmemoised callback — and the fix belongs there.
The custom hook, which is the whole point
The syntax is the smaller half of the release. The reason it matters is that a function calling hooks is itself a hook, with no registration, no base class and no involvement from React at all.
function useOrderStatus(orderId) {
const [status, setStatus] = useState(null);
useEffect(() => {
const sub = subscribe(orderId, setStatus);
return () => sub.unsubscribe();
}, [orderId]);
return status;
}
// four components, one implementation
function OrderStatus({ orderId }) {
return <Badge status={useOrderStatus(orderId)} />;
}
Each caller gets its own state — a hook is not a shared store, and two components calling useOrderStatus have two independent subscriptions. That is worth being explicit about, because the shape looks like a singleton and behaves like a constructor.
The use prefix is not decoration. It is how the linter knows to apply the rules, and a hook named without it silently loses that checking — which matters more than the convention argument suggests, because the rules are the only thing preventing a class of silent corruption.
The rules, and why the linter is not optional
Hooks are matched to their state by call order. Calling one conditionally shifts every subsequent hook onto the wrong slot, and the failure is data appearing in the wrong variable rather than an error.
{
"plugins": ["react-hooks"],
"rules": {
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn"
}
}
React does throw when the hook count changes between renders, but only when the count changes — a conditional that happens to keep it the same corrupts state silently. rules-of-hooks should be an error from the first day. exhaustive-deps as a warning is the pragmatic setting: it is right far more often than intuition and it has genuine false positives, so making it an error produces a codebase full of suppression comments.
useEffect is not componentDidUpdate
The mapping people reach for is wrong in one specific and consequential way: effects run after paint, and componentDidUpdate runs before it.
// runs after the browser has painted. a DOM measurement here
// causes a visible flicker as the layout corrects itself.
useEffect(() => {
setWidth(ref.current.getBoundingClientRect().width);
});
// runs synchronously after DOM mutation, before paint.
// this is the componentDidUpdate equivalent.
useLayoutEffect(() => {
setWidth(ref.current.getBoundingClientRect().width);
});
For almost everything the asynchronous version is correct and faster, which is why it is the default. The exception is anything measuring or mutating the DOM before the user sees it — a tooltip position, a scroll restore — where useEffect produces a one-frame flicker that is obvious on a slow device and invisible on yours.
useReducer, where four setters are one state machine
A component with loading, error and data as three pieces of state has four setters that are always called together, and every transition has to remember all three. It is a state machine written as independent variables, and invalid combinations are expressible.
function reducer(state, action) {
switch (action.type) {
case 'load': return { loading: true, error: null, data: null };
case 'loaded': return { loading: false, error: null, data: action.data };
case 'failed': return { loading: false, error: action.error, data: null };
default: throw new Error(`unknown action ${action.type}`);
}
}
function Orders({ id }) {
const [state, dispatch] = useReducer(reducer, {
loading: true, error: null, data: null,
});
useEffect(() => {
let live = true;
dispatch({ type: 'load' });
fetchOrders(id).then(
data => live && dispatch({ type: 'loaded', data }),
error => live && dispatch({ type: 'failed', error })
);
return () => { live = false; };
}, [id]);
}
Loading and error simultaneously stops being possible, because the transitions are named and enumerable rather than assembled at each call site. The reducer is a pure function taking a state and an action, so it is testable with no React at all — which is the benefit people notice second and value most.
dispatch is stable across renders, so passing it to a child needs no useCallback and it can safely be omitted from a dependency array. That is a small thing and it removes one of the most common sources of the memoisation churn described below.
The live flag in that effect is doing the same job an abort would, for a promise that cannot be cancelled: without it, a fast navigation dispatches against a component that has unmounted and React warns. It is the pattern to reach for whenever the async work has no cancellation of its own.
What not to reach for on the first day
Two of the eleven hooks are optimisations and both are routinely applied before anything has been measured, which makes code slower and harder to read at the same time.
// pointless on its own: Row re-renders regardless of this
const onSelect = useCallback(id => setSelected(id), []);
return rows.map(r => <Row key={r.id} row={r} onSelect={onSelect} />);
// it only does something once the child compares props
const Row = React.memo(function Row({ row, onSelect }) { /* ... */ });
// and memo compares shallowly, so this defeats it again
<Row style={{ padding: 8 }} items={rows.filter(Boolean)} />
useCallback without a memoised consumer allocates and compares a dependency array on every render to avoid an allocation that was not costing anything. React.memo around a component whose parent passes an inline object literal never skips a render, because the object is a new reference every time. The two have to be introduced together and only where a profile says so.
The other legitimate use of useCallback is when the function is itself a dependency of an effect, where a changing identity causes a re-subscription rather than a re-render — that one is a correctness fix rather than an optimisation and is worth applying without measuring.
Verifying it worked
$ npx eslint src --rulesdir ...
0 errors, 41 warnings (exhaustive-deps)
# and the test, which no longer needs a component
$ npx jest useOrderStatus
PASS src/hooks/useOrderStatus.test.js
✓ subscribes on mount
✓ resubscribes when the id changes ← the bug the classes had
✓ unsubscribes on unmount
$ git diff --stat src/components/
4 files changed, 62 insertions(+), 218 deletions(-)The middle test is the one that justifies the migration: it is the behaviour two of the four class components had lost, and it is now impossible to lose because there is one implementation. Testing the hook directly with renderHook rather than through a component is what makes the test about behaviour rather than about markup.
Forty-one warnings from the dependency rule is a normal first result and each one is worth reading rather than suppressing. On that codebase eleven were real — effects reading a prop they had not declared — and the rest were callbacks that wanted memoising upstream.
What this costs
Two idioms in one codebase, for years. There is no deprecation of class components and no plan for one, which is the right decision and means a mature application will have both indefinitely — new work in hooks, old components untouched until they need changing anyway. Rewriting working components to use hooks is churn with a risk attached, and the pressure to do it in February was almost entirely self-inflicted.
The subtler cost is that the mental model is genuinely different and the syntax hides that. A class component is an object with a lifetime; a function component is a function that runs many times and whose closures are snapshots. People who translate the syntax without absorbing the model write code that works in development and shows stale data in production, and the linter catches most but not all of it. Budgeting time for the team to read the rules properly, once, is cheaper than the alternative.