Comprehensive guide to using React hooks effectively, including built-in hooks, custom hooks, and performance optimization.
Master the most fundamental hooks with proper patterns for state management and side effects.
// ✅ Good useState patterns
interface User {
id: string;
name: string;
email: string;
}
interface FormData {
name: string;
email: string;
}
const UserProfile: React.FC<{ userId: string }> = ({ userId }) => {
// Initialize with proper types and null for async data
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Form state with initial values
const [formData, setFormData] = useState<FormData>({
name: '',
email: ''
});
// ✅ Functional state update when depending on previous state
const [count, setCount] = useState(0);
const increment = () => setCount(prev => prev + 1);
// ✅ Proper useEffect with dependencies and cleanup
useEffect(() => {
let cancelled = false;
const fetchUser = async () => {
try {
setIsLoading(true);
setError(null);
const userData = await getUserById(userId);
// Check if component is still mounted
if (!cancelled) {
setUser(userData);
setFormData({
name: userData.name,
email: userData.email
});
}
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'Unknown error');
}
} finally {
if (!cancelled) {
setIsLoading(false);
}
}
};
fetchUser();
// Cleanup function to prevent state updates on unmounted component
return () => {
cancelled = true;
};
}, [userId]); // Only re-run when userId changes
// ✅ Effect for setting up subscriptions with cleanup
useEffect(() => {
if (!user) return;
const subscription = subscribeToUserUpdates(user.id, (updatedUser) => {
setUser(updatedUser);
});
return () => {
subscription.unsubscribe();
};
}, [user?.id]);
// ✅ Update form field with proper state management
const handleInputChange = (field: keyof FormData) => (
event: React.ChangeEvent<HTMLInputElement>
) => {
setFormData(prev => ({
...prev,
[field]: event.target.value
}));
};
if (isLoading) return <LoadingSpinner />;
if (error) return <ErrorMessage message={error} />;
if (!user) return <EmptyState />;
return (
<form>
<input
value={formData.name}
onChange={handleInputChange('name')}
placeholder="Name"
/>
<input
value={formData.email}
onChange={handleInputChange('email')}
placeholder="Email"
/>
</form>
);
};
// ❌ Avoid - common useState/useEffect mistakes
const BadComponent = ({ userId }: { userId: string }) => {
const [user, setUser] = useState(); // No type annotation
const [data, setData] = useState({}); // Generic object
// ❌ Missing dependency array - runs on every render
useEffect(() => {
fetchUser();
});
// ❌ Missing cleanup - can cause memory leaks
useEffect(() => {
const timer = setInterval(() => {
console.log('Timer running');
}, 1000);
// Missing: return () => clearInterval(timer);
}, []);
// ❌ Direct state mutation
const handleUpdate = () => {
user.name = 'New Name'; // Mutating state directly
setUser(user); // Won't trigger re-render
};
return <div>{user?.name}</div>;
}; Use performance hooks strategically to prevent unnecessary re-renders and expensive calculations.
// ✅ useMemo for expensive calculations
interface ProductListProps {
products: Product[];
searchTerm: string;
sortBy: 'name' | 'price' | 'rating';
filterBy: string[];
}
const ProductList: React.FC<ProductListProps> = ({
products,
searchTerm,
sortBy,
filterBy
}) => {
// ✅ Memoize expensive filtering and sorting
const filteredAndSortedProducts = useMemo(() => {
let result = products.filter(product =>
product.name.toLowerCase().includes(searchTerm.toLowerCase()) &&
(filterBy.length === 0 || filterBy.includes(product.category))
);
return result.sort((a, b) => {
switch (sortBy) {
case 'name':
return a.name.localeCompare(b.name);
case 'price':
return a.price - b.price;
case 'rating':
return b.rating - a.rating;
default:
return 0;
}
});
}, [products, searchTerm, sortBy, filterBy]);
// ✅ Memoize calculated statistics
const productStats = useMemo(() => ({
totalProducts: filteredAndSortedProducts.length,
averagePrice: filteredAndSortedProducts.reduce((sum, p) => sum + p.price, 0) /
filteredAndSortedProducts.length,
averageRating: filteredAndSortedProducts.reduce((sum, p) => sum + p.rating, 0) /
filteredAndSortedProducts.length
}), [filteredAndSortedProducts]);
// ✅ useCallback to prevent child re-renders
const handleProductClick = useCallback((productId: string) => {
// Navigate to product details or trigger action
router.push(`/products/${productId}`);
}, [router]);
const handleAddToCart = useCallback((product: Product) => {
addToCart(product);
showNotification(`${product.name} added to cart`);
}, [addToCart, showNotification]);
// ✅ Memoized event handlers with dependencies
const handleSearch = useCallback(
debounce((term: string) => {
setSearchTerm(term);
analytics.track('product_search', { term });
}, 300),
[setSearchTerm, analytics]
);
return (
<div>
<ProductStats stats={productStats} />
<SearchInput onSearch={handleSearch} />
<div className="product-grid">
{filteredAndSortedProducts.map(product => (
<ProductCard
key={product.id}
product={product}
onClick={handleProductClick}
onAddToCart={handleAddToCart}
/>
))}
</div>
</div>
);
};
// ✅ Optimized child component with memo
interface ProductCardProps {
product: Product;
onClick: (productId: string) => void;
onAddToCart: (product: Product) => void;
}
const ProductCard = memo<ProductCardProps>(({ product, onClick, onAddToCart }) => {
const handleClick = () => onClick(product.id);
const handleAddToCart = () => onAddToCart(product);
return (
<div className="product-card" onClick={handleClick}>
<img src={product.imageUrl} alt={product.name} />
<h3>{product.name}</h3>
<p>${product.price}</p>
<button
onClick={(e) => {
e.stopPropagation();
handleAddToCart();
}}
>
Add to Cart
</button>
</div>
);
});
// ❌ Avoid - overusing performance hooks
const OverOptimizedComponent = ({ items }: { items: string[] }) => {
// ❌ Unnecessary memoization of simple operations
const itemCount = useMemo(() => items.length, [items]);
// ❌ Memoizing primitive values
const title = useMemo(() => 'My List', []);
// ❌ useCallback without proper dependencies
const handleClick = useCallback(() => {
console.log(items.length); // Uses items but not in dependency array
}, []);
return <div>{title}: {itemCount} items</div>;
}; Extract common logic into custom hooks to promote reusability and cleaner components.
// Custom hook for API data fetching
interface UseApiOptions<T> {
initialData?: T;
enabled?: boolean;
}
interface UseApiReturn<T> {
data: T | null;
isLoading: boolean;
error: string | null;
refetch: () => Promise<void>;
}
function useApi<T>(
url: string,
options: UseApiOptions<T> = {}
) {
const { initialData = null, enabled = true } = options;
const [data, setData] = useState<T | null>(initialData);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchData = useCallback(async () => {
try {
setIsLoading(true);
setError(null);
const response = await fetch(url);
if (!response.ok) {
throw new Error('HTTP error: ' + response.status);
}
const result = await response.json();
setData(result);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setIsLoading(false);
}
}, [url]);
useEffect(() => {
if (enabled) {
fetchData();
}
}, [fetchData, enabled]);
return { data, isLoading, error, refetch: fetchData };
} Leverage advanced patterns like useReducer for complex state and useRef for imperative operations.
// ✅ useReducer for complex state logic
interface TodoState {
todos: Todo[];
filter: 'all' | 'active' | 'completed';
isLoading: boolean;
}
type TodoAction =
| { type: 'ADD_TODO'; payload: { text: string } }
| { type: 'TOGGLE_TODO'; payload: { id: string } }
| { type: 'DELETE_TODO'; payload: { id: string } }
| { type: 'SET_FILTER'; payload: { filter: TodoState['filter'] } }
| { type: 'SET_LOADING'; payload: { isLoading: boolean } }
| { type: 'LOAD_TODOS'; payload: { todos: Todo[] } };
const todoReducer = (state: TodoState, action: TodoAction): TodoState => {
switch (action.type) {
case 'ADD_TODO':
return {
...state,
todos: [
...state.todos,
{
id: crypto.randomUUID(),
text: action.payload.text,
completed: false,
createdAt: new Date()
}
]
};
case 'TOGGLE_TODO':
return {
...state,
todos: state.todos.map(todo =>
todo.id === action.payload.id
? { ...todo, completed: !todo.completed }
: todo
)
};
case 'DELETE_TODO':
return {
...state,
todos: state.todos.filter(todo => todo.id !== action.payload.id)
};
case 'SET_FILTER':
return {
...state,
filter: action.payload.filter
};
case 'SET_LOADING':
return {
...state,
isLoading: action.payload.isLoading
};
case 'LOAD_TODOS':
return {
...state,
todos: action.payload.todos,
isLoading: false
};
default:
return state;
}
};
const TodoApp = () => {
const [state, dispatch] = useReducer(todoReducer, {
todos: [],
filter: 'all',
isLoading: true
});
const filteredTodos = useMemo(() => {
switch (state.filter) {
case 'active':
return state.todos.filter(todo => !todo.completed);
case 'completed':
return state.todos.filter(todo => todo.completed);
default:
return state.todos;
}
}, [state.todos, state.filter]);
const addTodo = useCallback((text: string) => {
dispatch({ type: 'ADD_TODO', payload: { text } });
}, []);
const toggleTodo = useCallback((id: string) => {
dispatch({ type: 'TOGGLE_TODO', payload: { id } });
}, []);
return (
<div>
<AddTodoForm onAdd={addTodo} />
<TodoFilters
currentFilter={state.filter}
onFilterChange={(filter) => dispatch({ type: 'SET_FILTER', payload: { filter } })}
/>
<TodoList todos={filteredTodos} onToggle={toggleTodo} />
</div>
);
};
// ✅ useRef for imperative operations and DOM access
const FocusableInput = () => {
const inputRef = useRef<HTMLInputElement>(null);
const [value, setValue] = useState('');
// Focus input on mount
useEffect(() => {
inputRef.current?.focus();
}, []);
const handleClear = () => {
setValue('');
inputRef.current?.focus();
};
return (
<div>
<input
ref={inputRef}
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="Type something..."
/>
<button onClick={handleClear}>Clear</button>
</div>
);
};
// ✅ useRef for storing mutable values
const Timer = () => {
const [time, setTime] = useState(0);
const [isRunning, setIsRunning] = useState(false);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
const startTimer = useCallback(() => {
if (intervalRef.current) return;
setIsRunning(true);
intervalRef.current = setInterval(() => {
setTime(prev => prev + 1);
}, 1000);
}, []);
const stopTimer = useCallback(() => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
setIsRunning(false);
}, []);
const resetTimer = useCallback(() => {
stopTimer();
setTime(0);
}, [stopTimer]);
// Cleanup on unmount
useEffect(() => {
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, []);
return (
<div>
<div>Time: {time}s</div>
<button onClick={startTimer} disabled={isRunning}>
Start
</button>
<button onClick={stopTimer} disabled={!isRunning}>
Stop
</button>
<button onClick={resetTimer}>Reset</button>
</div>
);
};
// ✅ useImperativeHandle for exposing component methods
interface ModalHandle {
open: () => void;
close: () => void;
}
interface ModalProps {
children: React.ReactNode;
title?: string;
}
const Modal = forwardRef<ModalHandle, ModalProps>(({ children, title }, ref) => {
const [isOpen, setIsOpen] = useState(false);
useImperativeHandle(ref, () => ({
open: () => setIsOpen(true),
close: () => setIsOpen(false)
}));
if (!isOpen) return null;
return (
<div className="modal-overlay">
<div className="modal">
{title && <h2>{title}</h2>}
{children}
<button onClick={() => setIsOpen(false)}>Close</button>
</div>
</div>
);
});
// Usage with imperative handle
const App = () => {
const modalRef = useRef<ModalHandle>(null);
return (
<div>
<button onClick={() => modalRef.current?.open()}>
Open Modal
</button>
<Modal ref={modalRef} title="Confirmation">
<p>Are you sure you want to proceed?</p>
<button onClick={() => modalRef.current?.close()}>
Confirm
</button>
</Modal>
</div>
);
};