Ask any LLM to write a hook that returns a function and it'll wrap it in useCallback. Ask for a component with state that depends on other state and you'll get a useEffect managing it. The code looks professional and reads cleanly. It's also quietly wrong. LLMs learned from the same sources we did: Stack Overflow answers from 2019, tutorials that predated the hooks docs. They reproduce those patterns with complete confidence, at scale. Mental models have always spread faster than documentation, and now they spread faster than ever.
React didn't help either. Class components had lifecycle methods, hooks replaced that model, concurrent features changed some assumptions again — and now "where should I put state?" reads like a philosophy essay. Simplified mental models fill that gap, not accurate but stable enough to build habits on.
Developers aren't being careless. The models just happen to be convenient to think with. They generate plausible predictions often enough to feel reliable. And the ones that survive feel like they should be true. They're the kind of wrong that's almost right, which is the hardest kind to dislodge.
"Components Re-Render When Props Change"
React is literally called React. It responds to things, and JSX looks like components are watching their inputs. The mental model people arrive at feels like the obvious interpretation: a component observes its state and props, and when those change, it re-renders. Props don't change, no re-render. It sounds right, given the name.
A component re-renders when its parent re-renders, regardless of whether its own props changed — or when its own state or context changes, but that's the part people usually get right.
React renders by calling every component function in the tree, then reconciles by diffing against the previous result to find what actually changed. A parent re-rendering pulls all its children into that pass, props or not. (React.memo is the opt-out, though it's often reached for before understanding why the re-render happened in the first place)
Consider a parent that holds some state and renders a child that receives none of it as props. Parent state updates, parent re-renders, child re-renders too. The child's props haven't changed.
function Parent() {
const [count, setCount] = useState(0);
return (
<>
<button onClick={() => setCount(c => c + 1)}>Click</button>
<Child />
</>
);
}
function Child() {
console.log('Child rendered'); // fires on every parent click
return <div>I have no props from Parent</div>;
}
Wrapping a child in React.memo makes sense because "so the props don't change." Wrapping a value in useMemo makes sense because "so the prop stays stable." The whole optimization story hangs on the wrong model. memo works at the reconciliation step: if props are shallowly equal, React skips re-rendering entirely and the component function never gets called.
"useMemo and useCallback Are Optimizations You Add When In Doubt"
In most codebases, useMemo is considered safe and professional. Not using it is the thing you have to justify.
The assumption underneath is that useMemo caches the result so React doesn't recompute it, and wrapping things in it is just getting free performance. But here's the thing: if you pass a plain object or array as a dependency, the memo is effectively useless.
function SearchResults({ query }) {
const options = { page: 1, sort: 'asc', query }; // new object every render
const result = useMemo(() => expensiveCalc(options), [options]);
// options is always a new reference — useMemo recalculates every time
}
Now, what does useMemo actually see here? A new object on every render — same shape, different reference. It invalidates, recalculates, and you've paid for the bookkeeping without getting any of the benefit. If options comes from useState, React guarantees the same reference as long as you haven't called the setter, and that's when useMemo actually does something.
useCallback has a similar problem, maybe more common. Wrapping a function in useCallback does nothing useful unless the receiving component is wrapped in React.memo, or the function is listed as a dependency in another hook. Without that, the child re-renders regardless and the callback memoization was effort that changed nothing. (And yet this is probably the most common hook pattern in production React codebases.) LLMs generate this pattern automatically — ask for a component and you'll get one with useCallback on every handler, doing nothing useful.
The irony is that people wrap things in useMemo to be "the developer who thinks about performance." But without understanding the dependency model, it often does nothing, or actively misleads the next person who assumes the memo is there for a reason and starts building around it. There's also memo's second parameter, a custom comparison function, which almost nobody knows exists but is probably more useful in edge cases than reflexive memoization.
The default should be: don't. If you can't point to a profiler trace that justifies the wrapper, you're paying the bookkeeping cost for nothing.
"useEffect Is How You React to Changes"
The mental model people import here comes from other tools: Vue's watch, MobX's reaction, that whole "observe this value, do something when it changes" pattern. Which, if you think about it, is a reasonable instinct. Why wouldn't React work the same way? "When filters changes, update filteredData." It just comes from somewhere else.
useEffect is for synchronizing with external systems React doesn't control: DOM APIs, WebSocket connections, third-party libraries. When you use it to sync two pieces of React state together, you're using the tool for something it wasn't meant to do.
The classic antipattern shows up in roughly every other "useEffect best practices" article as the example to avoid:
const [filters, setFilters] = useState({});
const [filteredData, setFilteredData] = useState([]);
useEffect(() => {
setFilteredData(data.filter(item => matchesFilters(item, filters)));
}, [data, filters]);
Two renders for every filter change. The filtered data could just be derived during render:
const filteredData = data.filter(item => matchesFilters(item, filters));
// no useState, no useEffect
A simpler version of the same mistake: initialize state from a prop, then write an effect to "sync" it when the prop changes. The prop could often just be the state directly. Or the component shouldn't own that state at all.
There's a reason MobX has loyal fans even as React dominates — it just fits the brain better. Observe data, react to changes: that's what people expect React to do too. Most developers nod when you explain the difference, then write a useEffect to sync state the next morning anyway.
The react.dev docs cover this now — there's a whole section called "You Might Not Need an Effect". But documentation doesn't fix how knowledge spreads. People learn React from colleagues who learned from other colleagues, from code reviews where nobody questioned the mental model. The patterns persist, and now they autocomplete. Which means the next generation of React developers will learn from LLMs that learned from us. Understanding where these models come from is the first step to not passing them on.
Top comments (0)