
You've been using useState, useEffect, and useContext for years now. They're muscle memory. You type them without thinking.
That's exactly the problem.
These three hooks are responsible for 90% of the bugs I've debugged in production React apps. Not because they're broken, but because we've all learned them wrong.
Here's what nobody told you when you started.
useState: The lie you've been told
Every tutorial teaches this:
const [count, setCount] = useState(0);
function increment() {
setCount(count + 1);
}
Simple. Clean. Broken.
Click that button fast enough, and you'll see it skip numbers. Why? Because count is a snapshot, not a live value. When React batches your clicks, they all read the same stale count.
The fix you should've learned first
const [count, setCount] = useState(0);
function increment() {
setCount(prevCount => prevCount + 1);
}
The functional update pattern isn't "advanced." It's the default way you should be writing state updates.
💡 Rule of thumb: If your new state depends on the previous state, use a function.
The object trap
This one catches everyone:
const [user, setUser] = useState({ name: 'Samy', age: 25 });
// ❌ This does nothing
function birthday() {
user.age += 1;
setUser(user);
}
React uses Object.is() to compare state. Same reference? No re-render. You mutated the object but gave React the exact same memory address.
// ✅ New object, new reference, React re-renders
function birthday() {
setUser(prev => ({ ...prev, age: prev.age + 1 }));
}
The mental model
Think of useState like a photograph, not a live camera feed.
Every render, you're looking at a frozen moment in time. The value won't change mid-render, no matter how many setCount calls you make.
useEffect: The most misunderstood hook
I've seen senior developers write this:
useEffect(() => {
fetchUserData();
}, []);
"Empty dependency array means it runs once on mount."
Sure. But why does it run once? What does the dependency array actually do?
It's not about "when to run"
The dependency array is a list of values your effect reads from the render scope.
function Profile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetchUser(userId).then(setUser);
}, [userId]); // Effect reads userId, so it's a dependency
}
When you lie about dependencies, you break the synchronization:
// ❌ You're lying - this effect reads userId but doesn't declare it
useEffect(() => {
fetchUser(userId).then(setUser);
}, []); // Bug: always fetches the first userId, ignores changes
The cleanup nobody writes
Here's a race condition hiding in plain sight:
useEffect(() => {
fetchUser(userId).then(data => {
setUser(data); // What if userId changed before this resolved?
});
}, [userId]);
If the user navigates fast enough, you'll display stale data. The fix:
useEffect(() => {
let cancelled = false;
fetchUser(userId).then(data => {
if (!cancelled) {
setUser(data);
}
});
return () => {
cancelled = true; // Cleanup: ignore stale responses
};
}, [userId]);
That cleanup function isn't optional decoration. It's load-bearing code.
The mental model
Think of useEffect as synchronization, not lifecycle.
Your effect is the bridge between React's world (state, props) and the outside world (APIs, subscriptions, DOM manipulation). The cleanup tears down the bridge before building a new one.
When NOT to use useEffect
This is probably the most important part. You don't need useEffect for:
Transforming data for render:
// ❌ Anti-pattern
const [items, setItems] = useState([]);
const [filteredItems, setFilteredItems] = useState([]);
useEffect(() => {
setFilteredItems(items.filter(i => i.active));
}, [items]);
// ✅ Just compute it
const filteredItems = items.filter(i => i.active);
Handling user events:
// ❌ Overengineered
const [query, setQuery] = useState('');
useEffect(() => {
search(query);
}, [query]);
// ✅ Just call it in the handler
function handleSearch(e) {
const value = e.target.value;
setQuery(value);
search(value);
}
Resetting state when props change:
// ❌ Effect for state reset
useEffect(() => {
setComment('');
}, [postId]);
// ✅ Key prop forces fresh instance
<CommentForm key={postId} />
useContext: The prop drilling killer (that can kill performance)
Context seems magical. Pass data through the component tree without props? Sign me up.
const ThemeContext = createContext('light');
function App() {
const [theme, setTheme] = useState('light');
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
<DeepNestedComponent />
</ThemeContext.Provider>
);
}
function DeepNestedComponent() {
const { theme } = useContext(ThemeContext);
return <div className={theme}>Hello</div>;
}
Beautiful. Until your app grinds to a halt.
The hidden re-render storm
Every time context value changes, every consumer re-renders. Even if they only use part of the value.
// ❌ This re-renders EVERYTHING on ANY change
<ThemeContext.Provider value={{ theme, user, settings, setTheme }}>
The problem? Every render creates a new object reference. React sees a different object, triggers re-renders everywhere.
The fix: stabilize your values
function App() {
const [theme, setTheme] = useState('light');
// Memoize the context value
const contextValue = useMemo(
() => ({ theme, setTheme }),
[theme] // Only recreate when theme changes
);
return (
<ThemeContext.Provider value={contextValue}>
<Children />
</ThemeContext.Provider>
);
}
The mental model
Think of context as a broadcast system:
When the DJ (Provider) changes the song (value), everyone in the club (consumers) reacts. Whether they wanted to hear that song or not.
When to split contexts
If different parts of your app care about different data, split them:
// Instead of one mega-context
const AppContext = createContext({ user, theme, settings });
// Split by concern
const UserContext = createContext(null);
const ThemeContext = createContext('light');
const SettingsContext = createContext({});
Now theme changes don't re-render components that only care about user data.
The cheat sheet
| Hook | Mental Model | Common Mistake | Fix |
|---|---|---|---|
useState | Snapshot, not live value | Mutating objects | Always create new references |
useEffect | Synchronization, not lifecycle | Lying about dependencies | List everything you read |
useContext | Broadcast to all consumers | Passing new objects each render | Memoize context values |
What's next?
These three hooks handle 80% of your React code. But when they're not enough, when performance matters, when you need DOM access, when state logic gets complex... that's when the next tier of hooks becomes essential.
In the next article, we'll cover useRef, useMemo, and useCallback. The hooks you should be using more than you are.
They're not "optimization hooks." They're correctness hooks that happen to also improve performance.
Continue to part 2: The hooks you should use more →
Got questions? Found a bug in my code? Hit me up on LinkedIn or check out more on my blog.