React Hooks Explained: useState and useEffect
You add a useEffect to fetch some data, open the Network tab, and see the same request firing over and over. Or a counter inside a setInterval stays stuck at 1 no matter how long you wait. Almost every React developer hits one of these early on, and both come from the same place: not quite knowing when useState and useEffect actually run.
This post covers those two hooks: how state updates work, what the dependency array really controls, when cleanup runs, and the mistakes that cause infinite loops and stale values. I'll also explain why they behave the way they do, because that's what makes the bugs easier to spot.
Before Hooks (added in React 16.8), sharing stateful logic between components meant render props or higher-order components, which tend to pile up as wrapper layers. Hooks let you pull that logic into a plain function and reuse it without changing your component tree.
The Evolution: From Classes to Hooks
Class components worked, but they had a few recurring pain points:
- Complex lifecycle methods: Logic was spread across
componentDidMount,componentDidUpdate, andcomponentWillUnmount - Confusing
thiskeyword: Binding event handlers and understandingthiscontext was error-prone - Hard to reuse logic: Sharing stateful logic between components required patterns that added complexity
- Classes don't minify well: Class methods don't minify as efficiently as functions
Hooks give function components state and lifecycle behavior, which removes most of that friction. Here's the same counter written both ways:
import React, { Component } from 'react';
class Counter extends Component {
constructor(props) {
super(props);
this.state = { count: 0 };
this.increment = this.increment.bind(this);
}
componentDidMount() {
document.title = `Count: ${this.state.count}`;
}
componentDidUpdate() {
document.title = `Count: ${this.state.count}`;
}
increment() {
this.setState({ count: this.state.count + 1 });
}
render() {
return (
<div>
<p>Count: {this.state.count}</p>
<button onClick={this.increment}>Increment</button>
</div>
);
}
}
import React, { useState, useEffect } from 'react';
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
document.title = `Count: ${count}`;
}, [count]);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>
Increment
</button>
</div>
);
}
The hooks version is shorter, there's no this to bind, and the title logic lives in one place instead of being copied into two lifecycle methods.
Understanding useState
Call useState with an initial value and you get back a pair: the current value and a function to change it. Calling that function schedules a re-render with the new value.
useState returns an array with the current state and an updater function
Basic Syntax
const [state, setState] = useState(initialValue);
// Examples with different types
const [count, setCount] = useState(0); // Number
const [name, setName] = useState(''); // String
const [isOpen, setIsOpen] = useState(false); // Boolean
const [items, setItems] = useState([]); // Array
const [user, setUser] = useState(null); // Object or null
const [form, setForm] = useState({ // Object
username: '',
email: '',
password: ''
});
The [state, setState] syntax is array destructuring. You can name these variables anything you want, but the convention is value and setValue. The order matters: state first, setter second.
Updating State
There are two ways to update state with the setter function:
// 1. Direct value update
setCount(5);
setName('John');
setIsOpen(true);
// 2. Functional update (recommended when new state depends on previous)
setCount(prevCount => prevCount + 1);
setItems(prevItems => [...prevItems, newItem]);
setUser(prevUser => ({ ...prevUser, name: 'Jane' }));
When the new state depends on the old one, use the functional form: setState(prev => newValue). The count variable is a snapshot from the current render, so calling setCount(count + 1) three times in one handler only adds 1. With setCount(c => c + 1) three times, you get 3.
Working with Objects and Arrays
State updates in React must be immutable. React compares the old and new value by reference, so if you mutate the object and pass the same reference back, it may skip the re-render entirely. Create a new object or array instead:
// ❌ WRONG - Mutating state directly
user.name = 'Jane';
setUser(user);
// ✅ CORRECT - Creating new object
setUser({ ...user, name: 'Jane' });
// Or with functional update:
setUser(prev => ({ ...prev, name: 'Jane' }));
// ❌ WRONG - Mutating array
items.push(newItem);
setItems(items);
// ✅ CORRECT - Creating new array
setItems([...items, newItem]);
// Or with functional update:
setItems(prev => [...prev, newItem]);
// Removing from array
setItems(prev => prev.filter(item => item.id !== idToRemove));
// Updating item in array
setItems(prev => prev.map(item =>
item.id === idToUpdate ? { ...item, completed: true } : item
));
Lazy Initialization
If your initial state requires expensive computation, pass a function to useState instead of a value:
// ❌ This runs on every render
const [data, setData] = useState(expensiveComputation());
// ✅ This only runs once on initial render
const [data, setData] = useState(() => expensiveComputation());
// Practical example: reading from localStorage
const [theme, setTheme] = useState(() => {
const saved = localStorage.getItem('theme');
return saved ? JSON.parse(saved) : 'dark';
});
Understanding useEffect
Rendering should only calculate what goes on screen. Anything that reaches outside the component (fetching data, subscribing to events, setting timers, touching the DOM directly) goes in useEffect, which runs after React has committed the render.
useEffect lifecycle: render → effect → cleanup (if returned)
Basic Syntax
useEffect(() => {
// Effect logic here (runs after render)
return () => {
// Cleanup logic here (optional)
// Runs before component unmounts
// Also runs before effect re-runs
};
}, [dependencies]); // Dependency array (optional)
The Three Dependency Array Patterns
The dependency array decides when your effect runs. Most useEffect bugs I see come down to getting this part wrong:
| Pattern | Syntax | When It Runs | Use Case |
|---|---|---|---|
| No Array | useEffect(() => {}) |
Every render | Rarely needed; usually a mistake |
| Empty Array | useEffect(() => {}, []) |
Only on mount | Initial data fetch, setup subscriptions |
| With Deps | useEffect(() => {}, [a, b]) |
When deps change | React to state/prop changes |
Practical Examples
Example 1: Data Fetching
import { useState, useEffect } from 'react';
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
// Reset state when userId changes
setLoading(true);
setError(null);
// Create abort controller for cleanup
const controller = new AbortController();
async function fetchUser() {
try {
const response = await fetch(
`https://api.example.com/users/${userId}`,
{ signal: controller.signal }
);
if (!response.ok) {
throw new Error('Failed to fetch user');
}
const data = await response.json();
setUser(data);
} catch (err) {
// Ignore abort errors
if (err.name !== 'AbortError') {
setError(err.message);
}
} finally {
setLoading(false);
}
}
fetchUser();
// Cleanup: cancel pending request
return () => controller.abort();
}, [userId]); // Re-fetch when userId changes
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
if (!user) return null;
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
);
}
Example 2: Setting Up Intervals
import { useState, useEffect } from 'react';
function Timer() {
const [seconds, setSeconds] = useState(0);
const [isRunning, setIsRunning] = useState(false);
useEffect(() => {
// Only set up interval if timer is running
if (!isRunning) return;
const intervalId = setInterval(() => {
setSeconds(prev => prev + 1);
}, 1000);
// Cleanup: clear interval when component unmounts
// or when isRunning changes to false
return () => clearInterval(intervalId);
}, [isRunning]); // Re-run when isRunning changes
return (
<div>
<h2>{seconds} seconds</h2>
<button onClick={() => setIsRunning(!isRunning)}>
{isRunning ? 'Pause' : 'Start'}
</button>
<button onClick={() => setSeconds(0)}>Reset</button>
</div>
);
}
Example 3: Event Listeners
import { useState, useEffect } from 'react';
function WindowSize() {
const [windowSize, setWindowSize] = useState({
width: window.innerWidth,
height: window.innerHeight,
});
useEffect(() => {
function handleResize() {
setWindowSize({
width: window.innerWidth,
height: window.innerHeight,
});
}
// Add event listener
window.addEventListener('resize', handleResize);
// Cleanup: remove event listener
return () => window.removeEventListener('resize', handleResize);
}, []); // Empty array = only on mount/unmount
return (
<div>
<p>Width: {windowSize.width}px</p>
<p>Height: {windowSize.height}px</p>
</div>
);
}
Example 4: Local Storage Sync
import { useState, useEffect } from 'react';
// Custom hook for localStorage sync
function useLocalStorage(key, initialValue) {
// Initialize state from localStorage or use default
const [value, setValue] = useState(() => {
const saved = localStorage.getItem(key);
return saved ? JSON.parse(saved) : initialValue;
});
// Sync to localStorage whenever value changes
useEffect(() => {
localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue];
}
// Usage
function Settings() {
const [theme, setTheme] = useLocalStorage('theme', 'dark');
const [fontSize, setFontSize] = useLocalStorage('fontSize', 16);
return (
<div>
<select value={theme} onChange={e => setTheme(e.target.value)}>
<option value="dark">Dark</option>
<option value="light">Light</option>
</select>
<input
type="range"
min="12"
max="24"
value={fontSize}
onChange={e => setFontSize(Number(e.target.value))}
/>
</div>
);
}
Common Mistakes and How to Avoid Them
Mistake 1: Missing Dependencies
// ❌ WRONG - count is used but not in dependencies
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
console.log(count); // Always logs initial value (0)!
}, 1000);
return () => clearInterval(id);
}, []); // Missing 'count' dependency
}
// ✅ CORRECT - Use functional update to avoid dependency
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(c => c + 1); // Uses latest count via callback
}, 1000);
return () => clearInterval(id);
}, []); // No dependency needed!
}
Mistake 2: Object/Array Dependencies
// ❌ WRONG - Creates new object every render, infinite loop!
function SearchResults({ query }) {
const [results, setResults] = useState([]);
const options = { query, limit: 10 }; // New object each render!
useEffect(() => {
fetchResults(options).then(setResults);
}, [options]); // Always triggers because options !== options
}
// ✅ CORRECT - Stable dependencies
function SearchResults({ query }) {
const [results, setResults] = useState([]);
const limit = 10;
useEffect(() => {
fetchResults({ query, limit }).then(setResults);
}, [query, limit]); // Primitive values are stable
}
// ✅ ALSO CORRECT - useMemo for complex objects
function SearchResults({ query }) {
const [results, setResults] = useState([]);
const options = useMemo(() => ({
query,
limit: 10
}), [query]);
useEffect(() => {
fetchResults(options).then(setResults);
}, [options]); // Now stable between renders
}
Mistake 3: Forgetting Cleanup
// ❌ WRONG - Memory leak! Event listener never removed
useEffect(() => {
window.addEventListener('scroll', handleScroll);
}, []);
// ✅ CORRECT - Always clean up subscriptions/listeners
useEffect(() => {
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
// ❌ WRONG - State update on unmounted component
useEffect(() => {
fetch('/api/data')
.then(res => res.json())
.then(data => setData(data)); // May error if unmounted
}, []);
// ✅ CORRECT - Cancel or ignore on unmount
useEffect(() => {
let isMounted = true;
fetch('/api/data')
.then(res => res.json())
.then(data => {
if (isMounted) setData(data);
});
return () => { isMounted = false; };
}, []);
Install eslint-plugin-react-hooks and turn on the exhaustive-deps rule. It flags missing dependencies before they turn into stale-value bugs. When it complains, the fix is usually to restructure the effect, not to add an eslint-disable comment.
In development with <React.StrictMode>, React 18 and later mount every component, unmount it, and mount it again. Your effect runs, its cleanup runs, then the effect runs again, so you'll see two fetches in the Network tab. This doesn't happen in a production build. It's there to expose effects without proper cleanup, so treat it as a test rather than something to work around. Also, the old "Can't perform a React state update on an unmounted component" warning was removed in React 18, so the isMounted pattern above is mainly about avoiding stale or out-of-order data, not silencing that warning.
Multiple useState and useEffect
A component can call as many hooks as it needs. I keep values that always change together in one state variable and split unrelated ones apart, with one effect per concern:
function Dashboard() {
// Separate concerns into different state variables
const [user, setUser] = useState(null);
const [posts, setPosts] = useState([]);
const [notifications, setNotifications] = useState([]);
const [isLoading, setIsLoading] = useState(true);
// Effect for user data
useEffect(() => {
fetchUser().then(setUser);
}, []);
// Effect for posts (depends on user)
useEffect(() => {
if (!user) return;
fetchPosts(user.id).then(setPosts);
}, [user]);
// Effect for notifications (polling)
useEffect(() => {
if (!user) return;
const fetchNotifs = () => {
fetchNotifications(user.id).then(setNotifications);
};
fetchNotifs(); // Initial fetch
const id = setInterval(fetchNotifs, 30000); // Poll every 30s
return () => clearInterval(id);
}, [user]);
// Effect to update loading state
useEffect(() => {
if (user && posts.length > 0) {
setIsLoading(false);
}
}, [user, posts]);
// ... render logic
}
Rules of Hooks
There are two rules. Break them and React mixes up which state belongs to which hook:
Rule 1: Top Level Only
Don't call hooks inside loops, conditions, or nested functions
Rule 2: React Functions Only
Only call hooks from React function components or custom hooks
Why It Matters
React relies on call order to track hooks between renders
// ❌ WRONG - Hook inside condition
function Form({ isEditing }) {
if (isEditing) {
const [value, setValue] = useState(''); // Never do this!
}
}
// ❌ WRONG - Hook inside loop
function List({ items }) {
items.forEach(item => {
useEffect(() => { /* ... */ }); // Never do this!
});
}
// ✅ CORRECT - Hooks at top level, conditions inside
function Form({ isEditing }) {
const [value, setValue] = useState('');
useEffect(() => {
if (isEditing) { // Condition inside hook is fine
// ... effect logic
}
}, [isEditing]);
}
// ✅ CORRECT - For lists, put hook in child component
function List({ items }) {
return items.map(item => (
<ListItem key={item.id} item={item} />
));
}
function ListItem({ item }) {
useEffect(() => { /* ... */ }, [item]); // Each instance has its own hook
return <div>{item.name}</div>;
}
When to Create Custom Hooks
A custom hook is just a function whose name starts with use and that calls other hooks. I reach for one when:
- You're duplicating hook logic across components
- A component's hook logic is getting complex
- You want to share stateful logic (not UI) between components
// Custom hook for API calls
function useApi(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController();
setLoading(true);
setError(null);
fetch(url, { signal: controller.signal })
.then(res => {
if (!res.ok) throw new Error('Request failed');
return res.json();
})
.then(setData)
.catch(err => {
if (err.name !== 'AbortError') setError(err);
})
.finally(() => setLoading(false));
return () => controller.abort();
}, [url]);
return { data, loading, error };
}
// Usage - so clean!
function UserProfile({ userId }) {
const { data: user, loading, error } = useApi(`/api/users/${userId}`);
if (loading) return <Spinner />;
if (error) return <Error message={error.message} />;
return <Profile user={user} />;
}
function PostList() {
const { data: posts, loading } = useApi('/api/posts');
// ...
}
When I wouldn't reach for useEffect
Cases where an effect is the wrong tool
- Derived values: If you can calculate something from props or state during render (a filtered list, a full name), just calculate it. Storing it in state and syncing it with an effect adds an extra render and a chance for the two to drift apart. The
isLoadingeffect in the Dashboard example above is a candidate for this: it could be computed directly. - Responding to a click: If something happens because the user did something, put it in the event handler. An effect that watches a "submitted" flag to send a request is harder to follow and easier to fire twice.
- Serious data fetching: The fetch-in-effect pattern is fine for small apps, but it doesn't give you caching, deduplication or retries. Once several components load the same data, I'd use TanStack Query, SWR, or your framework's data loading instead.
- Values that don't need a re-render: A timer ID or the previous value of a prop belongs in
useRef, notuseState.
A few problems in this post have better answers elsewhere:
useReducer: when several state values change together anduseStatecalls start to sprawluseRef: mutable values and DOM nodes that shouldn't trigger rendersuseMemoanduseCallback: keeping object and function dependencies stable, as in Mistake 2useContext: reading shared values without passing props through every level
If you only remember one thing, make it this: an effect is for syncing with something outside React. If there's no outside system involved, there's probably a simpler way to write it.