Originally published at adityarawas.in
React Hooks, introduced in React 16.8, changed how we write components. Before hooks, state and lifecycle logic required class components. Now, useState and useEffect let you add the same capabilities to simple functions.
This guide covers both hooks deeply — how they work under the hood, when to use them, and the subtle bugs that trip up even experienced React developers.
What Are Hooks?
Hooks are functions that let you “hook into” React’s internal state and lifecycle systems from function components. They always start with use.
The rules are strict:
- Only call hooks at the top level — never inside loops, conditions, or nested functions
- Only call hooks from React function components (or custom hooks)
These rules exist because React tracks hooks by their call order. If a hook runs conditionally, the order changes between renders and React’s internal state tracking breaks.
useState: Managing Component State
The Basics
import { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
}
Enter fullscreen mode Exit fullscreen mode
useState(initialValue) returns an array with two elements:
- The current state value (
count) - A setter function (
setCount) that triggers a re-render when called
State Updates Are Asynchronous
React batches state updates and re-renders once after event handlers complete. This means reading state immediately after setting it gives the old value:
function Counter() {
const [count, setCount] = useState(0);
function handleClick() {
setCount(count + 1);
console.log(count); // Still 0 — state hasn't updated yet
}
}
Enter fullscreen mode Exit fullscreen mode
The Functional Update Form
When the next state depends on the previous state, use the functional update form — pass a function to the setter instead of a value:
// Unsafe — can use a stale value of count
setCount(count + 1);
// Safe — always uses the latest state
setCount((prevCount) => prevCount + 1);
Enter fullscreen mode Exit fullscreen mode
This matters when multiple updates happen in the same render cycle:
function handleTripleIncrement() {
// Bad — all three read the same stale `count` value, result: count + 1
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
// Good — each sees the result of the previous, result: count + 3
setCount((c) => c + 1);
setCount((c) => c + 1);
setCount((c) => c + 1);
}
Enter fullscreen mode Exit fullscreen mode
State with Objects
When state is an object, you must spread the previous state when updating — useState does not merge objects like setState in class components:
const [user, setUser] = useState({ name: "Aditya", age: 28, city: "Pune" });
// Wrong — replaces the entire state object, losing `name` and `city`
setUser({ age: 29 });
// Correct — spread previous state, then override the field you want
setUser((prev) => ({ ...prev, age: 29 }));
Enter fullscreen mode Exit fullscreen mode
Lazy Initial State
If computing the initial state is expensive, pass a function — it only runs once on mount:
// Bad — parses JSON on every render (even though only the first matters)
const [data, setData] = useState(JSON.parse(localStorage.getItem('data')));
// Good — function only runs once
const [data, setData] = useState(() => JSON.parse(localStorage.getItem('data')));
Enter fullscreen mode Exit fullscreen mode
useEffect: Handling Side Effects
What is a Side Effect?
A side effect is anything that reaches outside the component’s render — fetching data, subscriptions, timers, manually updating the DOM, logging.
React’s render must be pure — the same props/state always produces the same JSX. Side effects live in useEffect.
Basic Syntax
useEffect(() => {
// Side effect code runs after render
document.title = `Count: ${count}`;
});
Enter fullscreen mode Exit fullscreen mode
Without a second argument, this effect runs after every render. That’s rarely what you want.
The Dependency Array
The second argument to useEffect controls when the effect re-runs:
// Runs once on mount only (empty array = no dependencies)
useEffect(() => {
fetchData();
}, []);
// Runs on mount AND whenever `userId` changes
useEffect(() => {
fetchUser(userId);
}, [userId]);
// Runs after every render (omit the array entirely)
useEffect(() => {
logRender();
});
Enter fullscreen mode Exit fullscreen mode
The rule: every reactive value used inside the effect (props, state, variables derived from them) must be in the dependency array. The react-hooks/exhaustive-deps ESLint rule enforces this automatically.
Fetching Data with useEffect
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false; // prevents stale state updates
async function fetchUser() {
try {
setLoading(true);
const res = await fetch(`/api/users/${userId}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
if (!cancelled) setUser(data);
} catch (err) {
if (!cancelled) setError(err.message);
} finally {
if (!cancelled) setLoading(false);
}
}
fetchUser();
return () => {
cancelled = true; // cleanup — ignore result if component unmounts
};
}, [userId]); // re-fetch whenever userId changes
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error}</p>;
return <p>{user?.name}</p>;
}
Enter fullscreen mode Exit fullscreen mode
Cleanup Functions
The function returned from useEffect is the cleanup function — React calls it before the effect runs again (due to a dependency change) and when the component unmounts.
useEffect(() => {
const interval = setInterval(() => {
setCount((c) => c + 1);
}, 1000);
return () => clearInterval(interval); // cleanup — prevents memory leaks
}, []);
Enter fullscreen mode Exit fullscreen mode
useEffect(() => {
const controller = new AbortController();
fetch('/api/data', { signal: controller.signal })
.then((res) => res.json())
.then(setData);
return () => controller.abort(); // cancel the in-flight request on cleanup
}, []);
Enter fullscreen mode Exit fullscreen mode
Cleanup is essential for:
- Cancelling fetch requests
- Clearing intervals and timeouts
- Unsubscribing from WebSocket connections or event listeners
- Cancelling animations
Common useEffect Mistakes
Mistake 1: Missing Dependencies
// Wrong — `userId` is used inside but missing from the array
// The effect runs once on mount with the initial userId, never again
useEffect(() => {
fetchUser(userId);
}, []); // eslint will warn about this
// Correct
useEffect(() => {
fetchUser(userId);
}, [userId]);
Enter fullscreen mode Exit fullscreen mode
Mistake 2: Object/Array Dependencies Causing Infinite Loops
Objects and arrays are compared by reference in JavaScript. A new object is created on every render even if its contents are the same — so putting it in the dependency array triggers the effect on every render.
function MyComponent({ config }) {
// Bug — if config is created inline: <MyComponent config={{ debug: true }} />
// A new object reference on every parent render → infinite loop
useEffect(() => {
setupLibrary(config);
}, [config]);
}
Enter fullscreen mode Exit fullscreen mode
Solutions:
- Memoize the object with
useMemo - Destructure primitive values from the object and use those as dependencies
- Move the object outside the component if it never changes
Mistake 3: Fetching Without Cleanup
// Bug — if userId changes quickly (e.g. user types in a search box),
// multiple requests are in-flight. The last response might not be
// from the last request — race condition!
useEffect(() => {
fetch(`/api/users/${userId}`)
.then((r) => r.json())
.then(setUser);
}, [userId]);
Enter fullscreen mode Exit fullscreen mode
Always use the cancellation pattern (AbortController or a cancelled flag) shown in the fetch example above.
Mistake 4: Setting State Unconditionally on Unmounted Component
useEffect(() => {
fetchData().then((data) => {
setData(data); // might run after component unmounts — React warning
});
}, []);
Enter fullscreen mode Exit fullscreen mode
Fix with the cancelled flag or AbortController shown earlier.
The Mental Model: Synchronization, Not Lifecycle
The key to using useEffect correctly is to stop thinking about lifecycle methods (componentDidMount, componentDidUpdate, componentWillUnmount) and instead think about synchronization:
“This effect keeps [external system] synchronized with [these values].”
// Don't think: "run this when userId changes"
// Think: "keep the user profile in sync with userId"
useEffect(() => {
fetchUser(userId);
return () => cancelFetch();
}, [userId]);
Enter fullscreen mode Exit fullscreen mode
This framing makes the dependency array obvious — whatever the effect needs to stay synchronized with goes in the array.
useState vs useReducer
As state logic grows more complex, useReducer is often cleaner than multiple useState calls:
// Multiple useState — works but gets unwieldy
const [name, setName] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
// useReducer — groups related state together
const [state, dispatch] = useReducer(reducer, {
name: '',
loading: false,
error: null,
});
Enter fullscreen mode Exit fullscreen mode
Prefer useReducer when:
- Multiple state values that change together
- Next state depends on complex logic involving previous state
- You find yourself passing many state setters down to child components
Practical Patterns
Debounced Search
function SearchBar({ onSearch }) {
const [query, setQuery] = useState('');
useEffect(() => {
const timer = setTimeout(() => {
onSearch(query);
}, 400);
return () => clearTimeout(timer); // cancel on every keystroke
}, [query, onSearch]);
return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}
Enter fullscreen mode Exit fullscreen mode
Window Resize Listener
function useWindowWidth() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
const handleResize = () => setWidth(window.innerWidth);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []); // no deps — addEventListener is stable
return width;
}
Enter fullscreen mode Exit fullscreen mode
LocalStorage Sync
function usePersistentState(key, defaultValue) {
const [value, setValue] = useState(
() => JSON.parse(localStorage.getItem(key) ?? 'null') ?? defaultValue
);
useEffect(() => {
localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue];
}
Enter fullscreen mode Exit fullscreen mode
These patterns all follow the same structure: setup in the effect body, cleanup in the return function, dependencies declared honestly.
Key Takeaways
-
useState(initial)returns[currentValue, setter]— calling the setter triggers a re-render with the new state. - Use the functional update form
setCount(c => c + 1)when the new state depends on the old state. - When state is an object, always spread previous state to avoid overwriting unrelated fields.
-
useEffectruns after render. The dependency array controls when it re-runs — an empty array means mount-only. - Every reactive value used inside an effect must be in the dependency array — use the ESLint rule
exhaustive-depsto enforce this. - Always return a cleanup function for subscriptions, timers, and fetch requests to prevent memory leaks and race conditions.
- Think of
useEffectas synchronization, not as lifecycle hooks — this mental model makes dependencies obvious. - When state logic gets complex, migrate from multiple
useStatecalls touseReducer.