Hooks let a function component hold state and talk to the outside world, something only class components could do before. The two you will reach for most are useState and useEffect. Both look simple, and that is precisely why so many React bugs start with a misunderstanding of how they work.
useState
This hook returns the current value together with a function to update it.
import { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>+</button>
</div>
);
}
State updates are asynchronous
This is the number one source of confusion. Calling setCount does not change count on the next line. React schedules a re-render, and the new value only becomes visible on the following render.
function handleClick() {
setCount(count + 1);
setCount(count + 1);
console.log(count); // Still the old value
}
That code increments by one, not two. Both calls read the same count from the current render. The fix is the functional form, which receives the latest value as an argument:
setCount(prev => prev + 1);
setCount(prev => prev + 1); // Now it increments by two
The rule of thumb: whenever the next value is derived from the previous one, always use the functional form.
Do not mutate state directly
React compares object references to decide whether to re-render. Changing the contents of an existing object or array does not change its reference, so React sees nothing as having changed.
// Wrong, the array reference stays the same
todos.push(newTodo);
setTodos(todos);
// Right, a new array
setTodos([...todos, newTodo]);
Lazy initial state
The argument to useState is evaluated on every render, even though the result is only used once. If the value is expensive to compute, wrap it in a function:
// readFromLocalStorage() runs on every render
const [data, setData] = useState(readFromLocalStorage());
// Runs only once, on mount
const [data, setData] = useState(() => readFromLocalStorage());
useEffect
useEffect is for synchronising a component with something outside React: network requests, event subscriptions, timers, or browser APIs.
import { useState, useEffect } from 'react';
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
let ignore = false;
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => {
if (!ignore) setUser(data);
});
return () => {
ignore = true;
};
}, [userId]);
return user ? <h1>{user.name}</h1> : <p>Loading...</p>;
}
Notice the ignore variable and the returned function. Without them this example has a real race condition. If userId changes quickly from 1 to 2, two requests are in flight at once. If the response for user 1 arrives late, it overwrites the data for user 2 and the screen shows the wrong person. The cleanup function marks the older effect as stale so its result is discarded.
The version without cleanup that appears in short tutorials looks tidier, but it hides a bug that only surfaces on a slow network, which is exactly the condition real users experience.
The dependency array
[]means the effect runs once after mount[userId]means the effect reruns wheneveruserIdchanges- No array at all means the effect runs on every render
The rule is simple: every value from inside the component that the effect uses must appear in the dependency array. Removing a dependency so the effect βstops running constantlyβ treats the symptom rather than the cause, and what you get instead is a stale closure.
A stale closure happens when an effect captures a value from an old render and keeps using it:
// Broken, the interval always sees count as 0
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => clearInterval(id);
}, []);
// Correct, there is no need to read count at all
useEffect(() => {
const id = setInterval(() => {
setCount(prev => prev + 1);
}, 1000);
return () => clearInterval(id);
}, []);
Install the react-hooks ESLint plugin. The exhaustive-deps warning that people often find annoying is exactly what catches this class of bug before it reaches users.
Cleanup always comes in pairs
Anything you start inside an effect has to be stopped in the cleanup function: event subscriptions, timers, WebSocket connections, IntersectionObserver.
useEffect(() => {
const onResize = () => setWidth(window.innerWidth);
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
Without cleanup, listeners accumulate every time the component remounts and the application slowly grinds down. In development with Strict Mode, React deliberately mounts and unmounts effects twice to surface exactly this kind of oversight. If a component behaves strangely in development but is fine in production, that is usually the cause, and the thing to fix is the cleanup, not Strict Mode.
When You Do Not Need useEffect
This is the section that saves the most code. Many useEffect calls in real projects do not need to exist at all.
For derived values, just compute them during render. No extra state and no effect required:
// Overkill
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(firstName + ' ' + lastName);
}, [firstName, lastName]);
// This is enough
const fullName = firstName + ' ' + lastName;
The first version triggers two renders and opens the door to state going out of sync. The second version cannot be wrong.
To respond to a user action, write it in the event handler. If something should happen because a button was pressed, it belongs in the click handler, not in an effect watching a state change.
To filter or sort a list, do it during render. Only if the computation is genuinely heavy and measurably slow should you wrap it in useMemo.
A useful yardstick: useEffect is for synchronising with a system outside React. If no external system is involved, you most likely do not need it.
Summary
useState holds a value that changes; its updates are asynchronous and must be made without mutating the old value. useEffect connects a component to the outside world; it must be honest about its dependencies and must clean up whatever it starts. Everything else, for the most part, can simply be computed during render.