The useEffect hook is arguably the most powerful, yet most misunderstood, hook in React. When I first transitioned from class components to functional components, I treated useEffect like componentDidMount and componentDidUpdate. This was a huge mistake.
Here is how I reframed my understanding of useEffect to avoid infinite loops and memory leaks.
It’s About Synchronization, Not Lifecycles
The official React documentation puts it best: useEffect is for synchronizing your React component with an external system.
External systems include:
- Network requests (APIs)
- Browser DOM (document.title, event listeners)
- Third-party libraries (like initializing a chart or a slider)
If you are using useEffect just to update a piece of state based on another piece of state, you are probably using it wrong.
The Dependency Array is a Contract
The second argument to useEffect—the dependency array—is a contract you make with React. You are saying: “Only re-run this synchronization if one of these specific variables has changed since the last render.”
useEffect(() => {
const handleScroll = () => console.log(window.scrollY);
window.addEventListener('scroll', handleScroll);
// The cleanup function is crucial to prevent memory leaks!
return () => window.removeEventListener('scroll', handleScroll);
}, []); // Empty array means: only run on mount and unmount
A Real-World Portfolio Example
In this portfolio, when you search for a blog post, I use useEffect to synchronize the search query state with the fuse.js filtering logic. However, I added a setTimeout inside the effect to “debounce” the search.
By cleaning up the timeout in the return function, I ensure that the search only runs after the user stops typing for 300ms, saving processing power and keeping the UI buttery smooth.
Mastering useEffect is a milestone for any React developer. It changes how you think about your UI, not as a sequence of events, but as a state machine synchronizing with the outside world.