I taught myself hooks in a weekend back in 2019, shipped a feature on Monday, and spent the next two weeks trying to figure out why a list re-rendered every time the user blinked. The honest answer is that I’d put a useState setter inside a useEffect that depended on the state I was setting. Classic. I rewrote the component four times before I read the Rules of Hooks page properly.
Six years later I’ve shipped a lot more React, and I’ve come around to thinking the hooks API is genuinely good. The official docs make it look easier than it is, though. Most “when to use hooks” posts I’ve read sound like a bullet-pointed cheat sheet from someone who’s never had to debug a stale closure at 11pm. So here’s mine. It’s the version I wish I’d had on Monday morning back in 2019, written from the bruises.
This isn’t a tutorial. If you’ve never written useState, go read the official intro first. This is the next layer: when each hook earns its keep, and when reaching for one is just adding noise.
The Rules of Hooks bit me hardest, and I had it coming
Everyone quotes the same two rules:
- Only call hooks at the top level.
- Only call hooks from React functions.
I read those, nodded, and then promptly broke rule one inside an early-return:
function ProfilePanel({ user }) {
if (!user) return null;
const [open, setOpen] = useState(false); // wrong: conditional hook
// ...
}
What’s bad about this is subtle. It works fine the first render, then crashes the moment user flips from null to an object, because React tracks hooks by call order. The newer ESLint plugin would catch this in five seconds. The version I was running back then didn’t. I lost most of an afternoon to it.
The fix is boring. Hoist the hook up, branch the JSX:
function ProfilePanel({ user }) {
const [open, setOpen] = useState(false);
if (!user) return null;
return open ? <FullPanel /> : <Collapsed onOpen={() => setOpen(true)} />;
}
I’ve now made the same mistake twice in seven years. The second time was inside an if (process.env.NODE_ENV === 'test') block, which I find embarrassing on every conceivable level. The lesson I took away: any time I want to skip a hook conditionally, the actual fix is splitting the component into two.
The Rules of Hooks page is short and worth re-reading every six months.
useState vs useReducer: when complexity sneaks in
Most of my components start with useState. That’s the default and it’s almost always right. The mistake I keep making is hanging on to useState past the point where it’s actually helping.
The signal that I should switch to useReducer is when I find myself writing this pattern:
function Editor() {
const [title, setTitle] = useState('');
const [body, setBody] = useState('');
const [isDirty, setIsDirty] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [errors, setErrors] = useState({});
const updateTitle = (t) => {
setTitle(t);
setIsDirty(true);
setErrors((e) => ({ ...e, title: undefined }));
};
// ... and so on for body, save, cancel, etc.
}
Five useState calls and every event handler has to remember to update three of them in the right order. The first time errors and isDirty got out of sync because I forgot to clear the error in one of nine handlers, I switched the whole component to useReducer and the bug class went away:
const initial = { title: '', body: '', isDirty: false, isSaving: false, errors: {} };
function reducer(state, action) {
switch (action.type) {
case 'edit-title':
return {
...state,
title: action.value,
isDirty: true,
errors: { ...state.errors, title: undefined },
};
case 'save-start': return { ...state, isSaving: true };
case 'save-success': return { ...initial };
}
}
function Editor() {
const [state, dispatch] = useReducer(reducer, initial);
// ...
}
The component got longer. The bugs went away. That’s the trade.
My rule of thumb: three or four pieces of state that always change together is the threshold. Below that, useState is fine. Above it, the implicit invariants start drifting and useReducer makes them explicit.
useEffect is the one I overuse
If I had to pick the hook I’ve abused the most, it would be useEffect. For about a year I treated it like a “do stuff after render” backdoor, which is exactly the framing the React team tells you to avoid. They were right. I just didn’t believe them until I’d written a bunch of bad code.
The pattern that ages worst:
function UserBadge({ user }) {
const [initials, setInitials] = useState('');
useEffect(() => {
setInitials(user.firstName[0] + user.lastName[0]);
}, [user]);
return <span>{initials}</span>;
}
Three renders to do what one variable could do:
function UserBadge({ user }) {
const initials = user.firstName[0] + user.lastName[0];
return <span>{initials}</span>;
}
I see this one in code reviews a lot. Anything you can derive from props or existing state should be derived during render, not stored in state and copied over with an effect. Effects are for synchronizing with something that lives outside React: the DOM, a websocket, a timer, an analytics SDK. If you’re not talking to something outside React, you probably don’t need one.
The other common mistake: putting fetch calls in useEffect for things that should be data dependencies. In a Next.js App Router project I’d reach for an async server component or a server action before adding a client-side fetch effect. In a SPA I’d reach for TanStack Query. useEffect for data fetching works, but it makes the loading state, error state, and race conditions your problem. They don’t have to be.
useMemo and useCallback aren’t free
I went through a phase where I wrapped everything in useMemo and useCallback, on the theory that more memoization had to be better. It isn’t. Both hooks add bookkeeping. React has to compare dependency arrays on every render, and that work only pays off if the memoized value or function is actually preventing real work somewhere downstream.
A function I wrap in useCallback and pass to a child that isn’t memoized? Doing nothing useful. That child re-renders anyway because its parent re-rendered. The useCallback just adds dependency-array checks for no reward.
Two cases where I do reach for them:
- The value or function is a dependency of another hook. If I have
useEffect(..., [handler])andhandleris recreated every render, the effect runs every render.useCallbackfixes that. - The child component is wrapped in
React.memoand receives the function or object as a prop. Now memoization actually does something.
Outside those two cases I don’t bother. And honestly, since the React Compiler started shipping, I’ve stopped reaching for useMemo and useCallback almost entirely on projects that have it enabled. The compiler does the memoization analysis for me, statically, with better information than I have at runtime.
Custom hooks: the part I actually got right
Custom hooks are the API I’ve changed my mind about least. They’re just functions that happen to call other hooks, and they’re the cleanest way I know to package up a piece of stateful logic so it can be reused.
The hook I’ve reused across four projects:
function useDebouncedValue(value, delay = 300) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id);
}, [value, delay]);
return debounced;
}
Twelve lines. Saves me from writing the same setTimeout cleanup dance in every search input I build. The shape (a useX(input) that returns an output) is the same shape as React’s built-in hooks, which is why custom hooks compose so easily.
Two rules I follow:
- A custom hook should have a single, nameable purpose.
useUserPreferencesis fine.useEverythingTheHeaderNeedsis a smell. - If a custom hook returns more than three things, I usually return them as an object so the call sites can destructure and skip what they don’t need.
If you want a sanity check on your custom hook design, the React docs page on reusing logic is genuinely good. I send it to junior devs more than any other React link.
What’s actually new in React 19 (and which ones I use)
A quick honest tour of the new hooks, because the noise around them has been a lot:
use(promise)is the one I reach for most. It lets a component suspend on a promise inline, which makes some data-fetching patterns much nicer to read. Pairs well with Suspense.useOptimisticgoes in any form that hits a slow endpoint. I wrote a longer post on the times I keep forgetting useOptimistic exists. It’s the one I most regret not reaching for sooner.useFormStatusanduseActionStateare useful inside Server Action forms in Next.js. I don’t use them in plain SPAs.useTransitionI’ve used twice. It’s fine for marking expensive UI updates as non-urgent. I haven’t found it as life-changing as the marketing suggested.
Most of the hooks I write are still useState, useEffect, and the occasional custom one. The new ones earn their keep in specific situations rather than replacing the old defaults.
What I’d actually do this week
If you’re trying to clean up a React codebase, here’s the thing I’d try first: pick one component that has more than three useState calls and look at every useEffect in it. Ask, for each effect, “is this synchronizing with something outside React, or am I copying state around?” If it’s the latter, see if you can derive the value during render or move it into the same setter call. I’ve done this exercise on three legacy components in the last month and removed about 40% of the effects. The components got smaller and the bugs got rarer. That’s the version of React I keep wanting to write.
If you want to see how I use this stuff in real projects, there’s some of my work on the portfolio that leans on these patterns more than the blog posts let on.