React Hooks: useState, useEffect, useContext Guide
React Hooks, introduced in version 16.8, fundamentally changed how we write React applications. Before hooks, managing state and lifecycle methods required class components with their verbose syntax...
Key Insights
- React Hooks eliminate the complexity of class components by enabling state and lifecycle features in functional components, reducing boilerplate by up to 40% in typical applications
- The dependency array in useEffect is critical—omitting it causes infinite loops, while an empty array runs once on mount, and populated arrays trigger effects when dependencies change
- useContext solves prop drilling but shouldn’t replace all state management; use it for truly global data like themes and authentication, not frequently-changing application state
Introduction to React Hooks
React Hooks, introduced in version 16.8, fundamentally changed how we write React applications. Before hooks, managing state and lifecycle methods required class components with their verbose syntax and confusing this binding. Hooks let you use state and other React features in functional components, making code more readable and reusable.
Here’s the difference:
// Class component - the old way
class Counter extends React.Component {
constructor(props) {
super(props);
this.state = { count: 0 };
this.increment = this.increment.bind(this);
}
increment() {
this.setState({ count: this.state.count + 1 });
}
render() {
return <button onClick={this.increment}>{this.state.count}</button>;
}
}
// Functional component with hooks - the modern way
function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
The hooks version is clearer, shorter, and easier to understand. This guide covers the three essential hooks you’ll use in virtually every React application: useState for state management, useEffect for side effects, and useContext for sharing data across components.
useState: Managing Component State
The useState hook is your primary tool for adding state to functional components. It returns an array with two elements: the current state value and a function to update it.
const [state, setState] = useState(initialValue);
Here’s a practical example with form inputs:
function UserForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [agreed, setAgreed] = useState(false);
const handleSubmit = (e) => {
e.preventDefault();
console.log({ email, password, agreed });
};
return (
<form onSubmit={handleSubmit}>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email"
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
/>
<label>
<input
type="checkbox"
checked={agreed}
onChange={(e) => setAgreed(e.target.checked)}
/>
I agree to terms
</label>
<button type="submit">Sign Up</button>
</form>
);
}
When working with objects or arrays, always create new references instead of mutating state:
function TodoList() {
const [todos, setTodos] = useState([]);
const [input, setInput] = useState('');
const addTodo = () => {
// Correct: Create new array
setTodos([...todos, { id: Date.now(), text: input, done: false }]);
setInput('');
};
const toggleTodo = (id) => {
// Correct: Map creates new array, spread creates new objects
setTodos(todos.map(todo =>
todo.id === id ? { ...todo, done: !todo.done } : todo
));
};
return (
<div>
<input value={input} onChange={(e) => setInput(e.target.value)} />
<button onClick={addTodo}>Add</button>
<ul>
{todos.map(todo => (
<li
key={todo.id}
onClick={() => toggleTodo(todo.id)}
style={{ textDecoration: todo.done ? 'line-through' : 'none' }}
>
{todo.text}
</li>
))}
</ul>
</div>
);
}
Best practice: Use multiple useState calls for unrelated state rather than one large object. This makes updates simpler and prevents unnecessary re-renders.
useEffect: Handling Side Effects
The useEffect hook handles side effects like data fetching, subscriptions, and DOM manipulation. It runs after render and can optionally clean up before the component unmounts or before re-running.
The dependency array is crucial:
useEffect(() => {
// Effect code
}, [dependencies]);
// No array: Runs after every render (usually wrong)
useEffect(() => { console.log('Every render'); });
// Empty array: Runs once on mount
useEffect(() => { console.log('Component mounted'); }, []);
// With dependencies: Runs when dependencies change
useEffect(() => { console.log('Count changed'); }, [count]);
Here’s a data fetching example:
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
const fetchUser = async () => {
try {
setLoading(true);
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
if (!cancelled) {
setUser(data);
setError(null);
}
} catch (err) {
if (!cancelled) {
setError(err.message);
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
};
fetchUser();
// Cleanup function prevents state updates on unmounted components
return () => {
cancelled = true;
};
}, [userId]); // Re-fetch when userId changes
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return <div>Welcome, {user.name}!</div>;
}
Cleanup functions are essential for subscriptions and event listeners:
function WindowSize() {
const [size, setSize] = useState({ width: window.innerWidth, height: window.innerHeight });
useEffect(() => {
const handleResize = () => {
setSize({ width: window.innerWidth, height: window.innerHeight });
};
window.addEventListener('resize', handleResize);
// Cleanup: Remove listener when component unmounts
return () => window.removeEventListener('resize', handleResize);
}, []); // Empty array: Set up once, clean up on unmount
return <div>{size.width} x {size.height}</div>;
}
useContext: Sharing State Across Components
Prop drilling—passing props through multiple component layers—becomes unwieldy in large applications. The useContext hook provides a clean solution for sharing data globally.
First, create a context:
import { createContext, useContext, useState } from 'react';
const ThemeContext = createContext();
function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
const toggleTheme = () => {
setTheme(prev => prev === 'light' ? 'dark' : 'light');
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
// Custom hook for cleaner consumption
function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within ThemeProvider');
}
return context;
}
Now any component can access the theme without prop drilling:
function App() {
return (
<ThemeProvider>
<Header />
<MainContent />
<Footer />
</ThemeProvider>
);
}
function Header() {
const { theme, toggleTheme } = useTheme();
return (
<header style={{ background: theme === 'light' ? '#fff' : '#333', color: theme === 'light' ? '#000' : '#fff' }}>
<h1>My App</h1>
<button onClick={toggleTheme}>
Switch to {theme === 'light' ? 'dark' : 'light'} mode
</button>
</header>
);
}
function MainContent() {
const { theme } = useTheme();
// Use theme without receiving it as a prop
return <main className={theme}>Content here</main>;
}
Combining Hooks: Real-World Example
Here’s a dashboard that combines all three hooks:
const AuthContext = createContext();
function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const login = (userData) => setUser(userData);
const logout = () => setUser(null);
return (
<AuthContext.Provider value={{ user, login, logout }}>
{children}
</AuthContext.Provider>
);
}
function Dashboard() {
const { user } = useContext(AuthContext);
const { theme } = useTheme();
const [stats, setStats] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!user) return;
const fetchStats = async () => {
setLoading(true);
const response = await fetch(`/api/stats/${user.id}`);
const data = await response.json();
setStats(data);
setLoading(false);
};
fetchStats();
}, [user]);
if (!user) return <div>Please log in</div>;
if (loading) return <div>Loading stats...</div>;
return (
<div className={`dashboard ${theme}`}>
<h2>Welcome, {user.name}</h2>
<div className="stats">
<div>Total Sales: ${stats.sales}</div>
<div>Active Users: {stats.activeUsers}</div>
</div>
</div>
);
}
Common Pitfalls and Best Practices
Rules of Hooks: Only call hooks at the top level of your component, never inside loops, conditions, or nested functions. This ensures hooks are called in the same order every render.
Stale closures: Be careful with dependency arrays. Missing dependencies can cause bugs:
// Wrong: count is stale
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
console.log(count); // Always logs 0
}, 1000);
return () => clearInterval(timer);
}, []); // Missing count dependency
// Correct: Use functional update
useEffect(() => {
const timer = setInterval(() => {
setCount(c => c + 1); // Always uses current value
}, 1000);
return () => clearInterval(timer);
}, []);
Performance: Don’t create new objects in render unnecessarily. Context value changes trigger re-renders in all consumers:
// Wrong: New object every render
<ThemeContext.Provider value={{ theme, toggleTheme }}>
// Better: Memoize the value
const value = useMemo(() => ({ theme, toggleTheme }), [theme]);
<ThemeContext.Provider value={value}>
Conclusion and Next Steps
Mastering useState, useEffect, and useContext gives you the foundation for building modern React applications. These three hooks handle the majority of common scenarios: local state, side effects, and global state sharing.
Once comfortable with these, explore useReducer for complex state logic, useMemo and useCallback for performance optimization, and useRef for accessing DOM elements and persisting values across renders. The React documentation and Dan Abramov’s blog posts on overreacted.io provide excellent deep dives into advanced patterns.
Start refactoring your class components to hooks today—you’ll immediately notice cleaner, more maintainable code.