Introduction
Hooks confused me for longer than I'd like to admit. Not the syntax — the syntax is simple. What confused me was when to use useEffect versus just writing normal logic in my component.
If you're at that stage right now, this article is for you. We're keeping this practical — real examples, real mistakes, no theory-heavy explanations of React's internal fiber architecture.
useState: Giving Your Component Memory
Without state, a component renders once and forgets everything. useState lets a component remember values between renders — and re-render automatically when that value changes.
import { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increase</button>
</div>
);
}
count is the current value. setCount is the function you call to update it. Calling setCount tells React "re-render this component with the new value" — you never update count directly.
Common Mistake: Updating State Based on Old State
This looks fine but can cause bugs:
setCount(count + 1);
setCount(count + 1); // still only adds 1, not 2!
Fix it with the function form:
setCount(prev => prev + 1);
setCount(prev => prev + 1); // now correctly adds 2
This matters more than it seems — always use the function form when your new state depends on the previous state.
useEffect: Running Code at the Right Time
useEffect lets you run code in response to a component rendering, or a specific value changing — things like fetching data, updating the page title, or setting up an event listener.
import { useState, useEffect } from 'react';
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetch(`https://api.example.com/users/${userId}`)
.then(res => res.json())
.then(data => setUser(data));
}, [userId]);
if (!user) return <p>Loading...</p>;
return <h2>{user.name}</h2>;
}
The array at the end — [userId] — is the dependency array. It tells React "only re-run this effect when userId changes."
The Three Dependency Array Patterns
useEffect(() => { /* code */ }); // runs after EVERY render
useEffect(() => { /* code */ }, []); // runs ONCE, after the first render
useEffect(() => { /* code */ }, [userId]); // runs when userId changes
Beginners almost always want one of the last two. An empty array [] is for one-time setup (like fetching initial data). A populated array is for "re-run this when X changes."
Cleaning Up After Effects
Some effects need cleanup — like removing an event listener when the component unmounts:
useEffect(() => {
const handleResize = () => console.log(window.innerWidth);
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, []);
The function you return inside useEffect is the cleanup function. React calls it automatically before the component unmounts, or before the effect runs again.
Common Beginner Mistakes
- Forgetting the dependency array entirely. This causes the effect to run after every single render, often triggering infinite fetch loops.
- Missing a dependency. If your effect uses a variable from outside it, add it to the array — ESLint's react-hooks/exhaustive-deps rule will warn you about this.
- Putting everything in useEffect. Not all logic needs to live in an effect. If you're just calculating a value from props or state, do it directly in the component body — no hook needed.
Final Thoughts
useState gives your component memory. useEffect lets it react to changes and the outside world (APIs, timers, event listeners). Once you separate those two jobs clearly in your head, hooks stop feeling mysterious.
Try rebuilding a small feature — a search box with an API call, for example — using exactly these two hooks. That's usually the exercise that makes it click.
Muhammad Farhan is a Frontend Developer specializing in React.js and Tailwind CSS, based in Dera Ismail Khan, Pakistan.
Portfolio: muhammad-farhan-dev.github.io/muhammadfarhan.dev