React Hooks transformed how we write React components, enabling state and lifecycle features in functional components. Understanding hooks deeply—beyond useState and useEffect—unlocks powerful patterns for building maintainable React applications. This guide covers mastering React Hooks from a senior developer's perspective.
Why Hooks Matter
Hooks revolutionized React development:
- Simpler Components: No more class boilerplate
- Logic Reuse: Custom hooks share stateful logic
- Composition: Combine small hooks into complex behavior
- Testability: Hooks are easier to test than lifecycle methods
- TypeScript: Better type inference than class components
Core Hooks
useState: Component State
import { useState } from 'react';
function Counter() {
// Basic usage
const [count, setCount] = useState(0);
// Lazy initialization (expensive computation)
const [data, setData] = useState(() => {
return computeExpensiveInitialValue();
});
// Object state
const [user, setUser] = useState({ name: '', email: '' });
// Update with callback (access previous value)
const increment = () => {
setCount(prev => prev + 1);
};
// Update object (spread to maintain other fields)
const updateName = (name) => {
setUser(prev => ({ ...prev, name }));
};
return (
<div>
<p>Count: {count}</p>
<button onClick={increment}>Increment</button>
<input
value={user.name}
onChange={(e) => updateName(e.target.value)}
/>
</div>
);
}
useEffect: Side Effects
import { useEffect, useState } from 'react';
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
// Run on every render (rarely needed)
useEffect(() => {
console.log('Component rendered');
});
// Run once on mount
useEffect(() => {
console.log('Component mounted');
// Cleanup on unmount
return () => {
console.log('Component unmounted');
};
}, []);
// Run when dependency changes
useEffect(() => {
let cancelled = false;
async function fetchUser() {
setLoading(true);
try {
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
// Prevent state update if the component is unmounted
if (!cancelled) {
setUser(data);
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
fetchUser();
// Cleanup: cancel pending request
return () => {
cancelled = true;
};
}, [userId]); // Re-run when userId changes
if (loading) return <div>Loading...</div>;
return <div>{user?.name}</div>;
}
useContext: Shared State
import { createContext, useContext, useState } from 'react';
// Create context
const ThemeContext = createContext(null);
// Provider component
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 consuming context
function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within ThemeProvider');
}
return context;
}
// Usage
function ThemedButton() {
const { theme, toggleTheme } = useTheme();
return (
<button
onClick={toggleTheme}
style={{
background: theme === 'light' ? '#fff' : '#333',
color: theme === 'light' ? '#333' : '#fff'
}}
>
Toggle Theme
</button>
);
}
useReducer: Complex State
import { useReducer } from 'react';
// Reducer function
function todoReducer(state, action) {
switch (action.type) {
case 'ADD':
return [...state, { id: Date.now(), text: action.text, done: false }];
case 'TOGGLE':
return state.map(todo =>
todo.id === action.id ? { ...todo, done: !todo.done } : todo
);
case 'DELETE':
return state.filter(todo => todo.id !== action.id);
case 'CLEAR_COMPLETED':
return state.filter(todo => !todo.done);
default:
throw new Error(`Unknown action: ${action.type}`);
}
}
function TodoList() {
const [todos, dispatch] = useReducer(todoReducer, []);
const [text, setText] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
if (text.trim()) {
dispatch({ type: 'ADD', text });
setText('');
}
};
return (
<div>
<form onSubmit={handleSubmit}>
<input
value={text}
onChange={(e) => setText(e.target.value)}
/>
<button type="submit">Add</button>
</form>
<ul>
{todos.map(todo => (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.done}
onChange={() => dispatch({ type: 'TOGGLE', id: todo.id })}
/>
<span style={{ textDecoration: todo.done ? 'line-through' : 'none' }}>
{todo.text}
</span>
<button onClick={() => dispatch({ type: 'DELETE', id: todo.id })}>
Delete
</button>
</li>
))}
</ul>
<button onClick={() => dispatch({ type: 'CLEAR_COMPLETED' })}>
Clear Completed
</button>
</div>
);
}
Performance Hooks
useMemo: Memoize Values
import { useMemo, useState } from 'react';
function ExpensiveList({ items, filter }) {
// Only recalculate when items or filter change
const filteredItems = useMemo(() => {
console.log('Filtering items...');
return items.filter(item =>
item.name.toLowerCase().includes(filter.toLowerCase())
);
}, [items, filter]);
// Memoize complex objects for child props
const sortedItems = useMemo(() => {
return [...filteredItems].sort((a, b) => a.name.localeCompare(b.name));
}, [filteredItems]);
return (
<ul>
{sortedItems.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
);
}
useCallback: Memoize Functions
import { useCallback, useState, memo } from 'react';
// Memoized child component
const Button = memo(function Button({ onClick, label }) {
console.log(`Rendering ${label}`);
return <button onClick={onClick}>{label}</button>;
});
function ParentComponent() {
const [count, setCount] = useState(0);
const [name, setName] = useState('');
// Without useCallback, this creates a new function every render
// const handleClick = () => setCount(c => c + 1);
// With useCallback, the function reference stays stable
const handleClick = useCallback(() => {
setCount(c => c + 1);
}, []);
// If the callback needs current state, include it in dependencies
const handleReset = useCallback(() => {
setCount(0);
setName('');
}, []);
return (
<div>
<p>Count: {count}</p>
<input value={name} onChange={(e) => setName(e.target.value)} />
{/*Button won't re-render when name changes*/}
<Button onClick={handleClick} label="Increment" />
<Button onClick={handleReset} label="Reset" />
</div>
);
}
useRef: Mutable Reference
import { useRef, useEffect, useState } from 'react';
function FocusInput() {
const inputRef = useRef(null);
// Focus on mount
useEffect(() => {
inputRef.current?.focus();
}, []);
return <input ref={inputRef} />;
}
function Timer() {
const [count, setCount] = useState(0);
const intervalRef = useRef(null);
useEffect(() => {
intervalRef.current = setInterval(() => {
setCount(c => c + 1);
}, 1000);
return () => clearInterval(intervalRef.current);
}, []);
const stop = () => {
clearInterval(intervalRef.current);
};
return (
<div>
<p>Count: {count}</p>
<button onClick={stop}>Stop</button>
</div>
);
}
// Track previous value
function usePrevious(value) {
const ref = useRef();
useEffect(() => {
ref.current = value;
});
return ref.current;
}
function Component({ value }) {
const prevValue = usePrevious(value);
return (
<p>
Current: {value}, Previous: {prevValue}
</p>
);
}
Custom Hooks
Data Fetching Hook
import { useState, useEffect } from 'react';
function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
async function fetchData() {
setLoading(true);
setError(null);
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`);
}
const result = await response.json();
if (!cancelled) {
setData(result);
}
} catch (err) {
if (!cancelled) {
setError(err.message);
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
fetchData();
return () => {
cancelled = true;
};
}, [url]);
return { data, loading, error };
}
// Usage
function UserList() {
const { data: users, loading, error } = useFetch('/api/users');
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return (
<ul>
{users?.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
Local Storage Hook
import { useState, useEffect } from 'react';
function useLocalStorage(key, initialValue) {
// Initialize from localStorage or use the initial value
const [value, setValue] = useState(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.error(error);
return initialValue;
}
});
// Sync to localStorage when the value changes
useEffect(() => {
try {
window.localStorage.setItem(key, JSON.stringify(value));
} catch (error) {
console.error(error);
}
}, [key, value]);
return [value, setValue];
}
// Usage
function Settings() {
const [theme, setTheme] = useLocalStorage('theme', 'light');
const [fontSize, setFontSize] = useLocalStorage('fontSize', 16);
return (
<div>
<select value={theme} onChange={(e) => setTheme(e.target.value)}>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
<input
type="range"
min="12"
max="24"
value={fontSize}
onChange={(e) => setFontSize(Number(e.target.value))}
/>
</div>
);
}
Debounce Hook
import { useState, useEffect } from 'react';
function useDebounce(value, delay) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
// Usage: Search with debounce
function SearchComponent() {
const [searchTerm, setSearchTerm] = useState('');
const debouncedSearch = useDebounce(searchTerm, 500);
const { data: results } = useFetch(
debouncedSearch ? `/api/search?q=${debouncedSearch}` : null
);
return (
<div>
<input
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search..."
/>
<ul>
{results?.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</div>
);
}
Window Size Hook
import { useState, useEffect } from 'react';
function useWindowSize() {
const [size, setSize] = useState({
width: window.innerWidth,
height: window.innerHeight
});
useEffect(() => {
const handleResize = () => {
setSize({
width: window.innerWidth,
height: window.innerHeight
});
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return size;
}
// Usage
function ResponsiveComponent() {
const { width } = useWindowSize();
return (
<div>
{width < 768 ? <MobileView /> : <DesktopView />}
</div>
);
}
Rules of Hooks
- Only call at top level: Never in loops, conditions, or nested functions
- Only call in React functions: Components or custom hooks
- Name custom hooks with "use": Enables lint rule checking
// BAD
function Component({ condition }) {
if (condition) {
const [value, setValue] = useState(0); // Error!
}
}
// GOOD
function Component({ condition }) {
const [value, setValue] = useState(0);
if (condition) {
// Use value here
}
}
Key Takeaways
- useState for simple state: Objects need spread for updates
- useEffect with cleanup: Prevent memory leaks and race conditions
- useCallback/useMemo carefully: Premature optimization is costly
- Custom hooks for reuse: Extract common patterns
- useReducer for complex state: Better than multiple useState
- Dependency arrays matter: Missing deps cause bugs
Hooks are the foundation of modern React—master them, and you'll write cleaner, more maintainable components.