2025-11-02
Why My React useEffect Kept Re-Firing (and the Vue Equivalent)
I spent an embarrassing amount of time last week chasing a useEffect that kept firing every render instead of only when a specific value changed. The dependency array looked right. It was not right.
The bug
function BoardColumn({ cards }) {
const [sorted, setSorted] = useState([]);
useEffect(() => {
setSorted(cards.sort((a, b) => a.order - b.order));
}, [cards]);
// ...
}
cards was an array literal built fresh on every parent render — [...someCards] a couple of components up. Same contents, new reference, every single time. React’s dependency comparison is reference equality, so the effect re-ran on every render regardless of whether anything meaningful had changed.
The fix
Two options: memoize the array where it’s created (useMemo), or depend on a primitive derived from it instead of the array itself — I went with the latter since it’s more obviously correct at a glance:
useEffect(() => {
setSorted([...cards].sort((a, b) => a.order - b.order));
}, [cards.map((c) => c.id).join(',')]);
Not elegant, but it makes the actual dependency (which cards, in which order) explicit instead of relying on reference identity nobody asked for.
The Vue equivalent
Rebuilding this same board in Vue right after made the underlying issue much clearer, because Vue’s watch defaults to deep reactivity on refs holding objects/arrays — you don’t hit this class of bug by default:
watch(
() => cards.value,
(newCards) => {
sorted.value = [...newCards].sort((a, b) => a.order - b.order);
},
{ deep: true }
);
Neither approach is “better” in the abstract. But it’s a good reminder that React’s reference-equality dependency model is a decision, not a law of nature — and it’s worth knowing what the alternative feels like before you’ve internalized enough React patterns to stop noticing it.