React Hooks Explained: useState and useEffect in Depth
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>
);
}
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
}
}
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);
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);
}
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 }));
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')));
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}`;
});
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();
});
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>;
}
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
}, []);
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
}, []);
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]);
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]);
}
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]);
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
});
}, []);
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]);
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,
});
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)} />;
}
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;
}
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];
}
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.
Never Miss an Article
Stay Updated
Get new deep-dives on JavaScript, TypeScript, Go, and cloud-native engineering delivered to your reader.
Written by
Aditya RawasFull-stack engineer writing deep-dives on JavaScript, TypeScript, React, AWS, Docker, and Kubernetes. Passionate about making complex engineering concepts accessible to developers at every level.