React Hooks Explained: useState and useEffect
React Hooks revolutionized how we write React components when they were introduced in React 16.8. They allow you to use state and other React features without writing class components. In this comprehensive guide, we'll dive deep into the two most fundamental hooks: useState and useEffect. By the end, you'll understand not just how to use them, but why they work the way they do.
Before Hooks, sharing stateful logic between components required complex patterns like render props or higher-order components. Hooks let you extract and reuse stateful logic without changing your component hierarchy. This makes your code simpler, more readable, and easier to test.
The Evolution: From Classes to Hooks
Before we dive into hooks, let's understand why they exist. Class components in React worked well but had several 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 solve all these problems by letting you use state and lifecycle features in functional components. Let's see the difference:
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 significantly shorter, easier to read, and avoids the this binding issues. Now let's explore each hook in detail.
Understanding useState
The useState hook is your gateway to adding state to functional components. It returns a pair: the current state value and a function to update it.
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 your new state depends on the previous state, always use the functional form: setState(prev => newValue). This ensures you're working with the most current state, especially important when multiple updates happen rapidly.
Working with Objects and Arrays
State updates in React must be immutable. Never mutate state directly—always create new references:
// ❌ 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
useEffect is the hook for handling side effects in functional components. Side effects are operations that reach outside the component: data fetching, subscriptions, manually changing the DOM, logging, etc.
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 controls when your effect runs. Understanding this is crucial:
| 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 enable the exhaustive-deps rule. It will warn you about missing dependencies and help prevent subtle bugs. Trust the linter—it's usually right!
Multiple useState and useEffect
You can (and should!) use multiple hooks in a single component. Keep related state together, separate unrelated state:
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
React enforces two important rules for hooks. Breaking them causes bugs:
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
Custom hooks let you extract component logic into reusable functions. Create a custom hook 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');
// ...
}
Summary and Best Practices
Key Takeaways
- useState: For managing local component state. Use functional updates when new state depends on previous state.
- useEffect: For side effects after render. Always specify dependencies and clean up subscriptions.
- Dependencies: Include all values from component scope that change over time and are used in the effect.
- Cleanup: Return a cleanup function for subscriptions, timers, and event listeners.
- Custom Hooks: Extract reusable stateful logic into custom hooks prefixed with
use.
Now that you've mastered useState and useEffect, explore these other hooks:
useContext- Access context without nestinguseReducer- Complex state logicuseMemoanduseCallback- Performance optimizationuseRef- Mutable values and DOM references
React Hooks have fundamentally changed how we write React applications. They make our code more readable, testable, and reusable. Start with useState and useEffect, practice the patterns we've covered, and you'll be writing elegant React code in no time!
