Custom hooks are one of React's most powerful features, yet many developers underutilize them. They allow you to extract component logic into reusable functions, making your code cleaner, more testable, and easier to maintain. Today, I'll show you how to build custom hooks that solve real-world problems with TypeScript for maximum type safety.
Understanding Custom Hooks
Custom hooks are JavaScript functions that:
- Start with the prefix "use" (React convention)
- Can call other hooks (built-in or custom)
- Encapsulate stateful logic that can be shared between components
- Return values that components can consume
Think of them as a way to "extract" the logic from your components without changing the hierarchy.
Basic Custom Hook Pattern
Let's start with a simple example - a counter hook:
// hooks/useCounter.ts
import { useState, useCallback } from 'react';
interface UseCounterOptions {
initialValue?: number;
min?: number;
max?: number;
step?: number;
}
interface UseCounterReturn {
count: number;
increment: () => void;
decrement: () => void;
reset: () => void;
setValue: (value: number) => void;
}
export const useCounter = (options: UseCounterOptions = {}): UseCounterReturn => {
const {
initialValue = 0,
min = -Infinity,
max = Infinity,
step = 1
} = options;
const [count, setCount] = useState(initialValue);
const increment = useCallback(() => {
setCount(prev => Math.min(max, prev + step));
}, [max, step]);
const decrement = useCallback(() => {
setCount(prev => Math.max(min, prev - step));
}, [min, step]);
const reset = useCallback(() => {
setCount(initialValue);
}, [initialValue]);
const setValue = useCallback((value: number) => {
setCount(Math.max(min, Math.min(max, value)));
}, [min, max]);
return {
count,
increment,
decrement,
reset,
setValue
};
};
Usage in a component:
// components/CounterComponent.tsx
import React from 'react';
import { useCounter } from '../hooks/useCounter';
const CounterComponent: React.FC = () => {
const { count, increment, decrement, reset } = useCounter({
initialValue: 0,
min: 0,
max: 100,
step: 5
});
return (
Counter: {count}
);
};
Advanced Custom Hooks
1. API Data Fetching Hook
Let's build a powerful data fetching hook with caching, loading states, and error handling:
// hooks/useFetch.ts
import { useState, useEffect, useCallback, useRef } from 'react';
interface UseFetchOptions {
initialData?: T;
enabled?: boolean;
cacheTime?: number;
onSuccess?: (data: T) => void;
onError?: (error: Error) => void;
}
interface UseFetchReturn {
data: T | null;
loading: boolean;
error: Error | null;
refetch: () => Promise;
mutate: (newData: T) => void;
}
// Simple cache implementation
const cache = new Map();
export const useFetch = (
url: string,
options: UseFetchOptions = {}
): UseFetchReturn => {
const {
initialData = null,
enabled = true,
cacheTime = 5 * 60 * 1000, // 5 minutes
onSuccess,
onError
} = options;
const [data, setData] = useState(initialData);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const abortControllerRef = useRef(null);
const fetchData = useCallback(async () => {
// Check cache first
const cached = cache.get(url);
if (cached && Date.now() - cached.timestamp < cacheTime) {
setData(cached.data);
onSuccess?.(cached.data);
return;
}
setLoading(true);
setError(null);
// Cancel previous request
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
abortControllerRef.current = new AbortController();
try {
const response = await fetch(url, {
signal: abortControllerRef.current.signal,
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result: T = await response.json();
// Cache the result
cache.set(url, { data: result, timestamp: Date.now() });
setData(result);
onSuccess?.(result);
} catch (err) {
if (err instanceof Error && err.name !== 'AbortError') {
const error = err as Error;
setError(error);
onError?.(error);
}
} finally {
setLoading(false);
}
}, [url, cacheTime, onSuccess, onError]);
const mutate = useCallback((newData: T) => {
setData(newData);
cache.set(url, { data: newData, timestamp: Date.now() });
}, [url]);
useEffect(() => {
if (enabled) {
fetchData();
}
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, [fetchData, enabled]);
return {
data,
loading,
error,
refetch: fetchData,
mutate
};
};
2. Local Storage Hook
A hook that syncs state with localStorage and handles serialization:
// hooks/useLocalStorage.ts
import { useState, useEffect, useCallback } from 'react';
type SetValue = (value: T | ((val: T) => T)) => void;
interface UseLocalStorageOptions {
serializer?: {
parse: (value: string) => any;
stringify: (value: any) => string;
};
}
export const useLocalStorage = (
key: string,
initialValue: T,
options: UseLocalStorageOptions = {}
): [T, SetValue, () => void] => {
const {
serializer = {
parse: JSON.parse,
stringify: JSON.stringify
}
} = options;
// Get from local storage then parse stored json or return initialValue
const [storedValue, setStoredValue] = useState(() => {
try {
if (typeof window === 'undefined') {
return initialValue;
}
const item = window.localStorage.getItem(key);
return item ? serializer.parse(item) : initialValue;
} catch (error) {
console.warn(`Error reading localStorage key "${key}":`, error);
return initialValue;
}
});
// Return a wrapped version of useState's setter function that persists the new value to localStorage
const setValue: SetValue = useCallback((value) => {
try {
// Allow value to be a function so we have the same API as useState
const valueToStore = value instanceof Function ? value(storedValue) : value;
// Save state
setStoredValue(valueToStore);
// Save to local storage
if (typeof window !== 'undefined') {
window.localStorage.setItem(key, serializer.stringify(valueToStore));
}
} catch (error) {
console.warn(`Error setting localStorage key "${key}":`, error);
}
}, [key, storedValue, serializer]);
// Remove from localStorage
const removeValue = useCallback(() => {
try {
setStoredValue(initialValue);
if (typeof window !== 'undefined') {
window.localStorage.removeItem(key);
}
} catch (error) {
console.warn(`Error removing localStorage key "${key}":`, error);
}
}, [key, initialValue]);
// Listen for changes in other tabs/windows
useEffect(() => {
const handleStorageChange = (e: StorageEvent) => {
if (e.key === key && e.newValue !== null) {
try {
setStoredValue(serializer.parse(e.newValue));
} catch (error) {
console.warn(`Error parsing localStorage value for key "${key}":`, error);
}
}
};
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, [key, serializer]);
return [storedValue, setValue, removeValue];
};
3. Debounce Hook
Perfect for search inputs and API calls:
// hooks/useDebounce.ts
import { useState, useEffect } from 'react';
interface UseDebounceOptions {
leading?: boolean;
trailing?: boolean;
maxWait?: number;
}
export const useDebounce = (
value: T,
delay: number,
options: UseDebounceOptions = {}
): T => {
const { leading = false, trailing = true, maxWait } = options;
const [debouncedValue, setDebouncedValue] = useState(
leading ? value : value
);
const [lastCallTime, setLastCallTime] = useState(0);
useEffect(() => {
const now = Date.now();
const timeSinceLastCall = now - lastCallTime;
// Leading edge
if (leading && (!lastCallTime || timeSinceLastCall >= delay)) {
setDebouncedValue(value);
setLastCallTime(now);
return;
}
// Set up the delayed function call
const handler = setTimeout(() => {
if (trailing) {
setDebouncedValue(value);
}
setLastCallTime(Date.now());
}, delay);
// Max wait logic
if (maxWait && timeSinceLastCall >= maxWait) {
setDebouncedValue(value);
setLastCallTime(now);
clearTimeout(handler);
}
// Cleanup function to cancel the timeout
return () => {
clearTimeout(handler);
};
}, [value, delay, leading, trailing, maxWait, lastCallTime]);
return debouncedValue;
};
// Alternative: Debounce a callback function
export const useDebouncedCallback = any>(
callback: T,
delay: number
): T => {
const [timeoutId, setTimeoutId] = useState(null);
const debouncedCallback = useCallback((...args: Parameters) => {
// Clear existing timeout
if (timeoutId) {
clearTimeout(timeoutId);
}
// Set new timeout
const newTimeoutId = setTimeout(() => {
callback(...args);
}, delay);
setTimeoutId(newTimeoutId);
}, [callback, delay, timeoutId]) as T;
// Cleanup on unmount
useEffect(() => {
return () => {
if (timeoutId) {
clearTimeout(timeoutId);
}
};
}, [timeoutId]);
return debouncedCallback;
};
Complex State Management Hook
Let's build a shopping cart hook that demonstrates complex state management:
// hooks/useShoppingCart.ts
import { useState, useCallback, useMemo } from 'react';
import { useLocalStorage } from './useLocalStorage';
interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
image?: string;
variant?: string;
}
interface UseShoppingCartReturn {
items: CartItem[];
totalItems: number;
totalPrice: number;
addItem: (item: Omit, quantity?: number) => void;
removeItem: (id: string) => void;
updateQuantity: (id: string, quantity: number) => void;
clearCart: () => void;
isInCart: (id: string) => boolean;
getItem: (id: string) => CartItem | undefined;
}
export const useShoppingCart = (): UseShoppingCartReturn => {
const [items, setItems] = useLocalStorage('shopping-cart', []);
// Memoized calculations
const totalItems = useMemo(() => {
return items.reduce((sum, item) => sum + item.quantity, 0);
}, [items]);
const totalPrice = useMemo(() => {
return items.reduce((sum, item) => sum + (item.price * item.quantity), 0);
}, [items]);
// Add item to cart
const addItem = useCallback((newItem: Omit, quantity = 1) => {
setItems(currentItems => {
const existingItemIndex = currentItems.findIndex(item => item.id === newItem.id);
if (existingItemIndex >= 0) {
// Item exists, update quantity
const updatedItems = [...currentItems];
updatedItems[existingItemIndex] = {
...updatedItems[existingItemIndex],
quantity: updatedItems[existingItemIndex].quantity + quantity
};
return updatedItems;
} else {
// New item, add to cart
return [...currentItems, { ...newItem, quantity }];
}
});
}, [setItems]);
// Remove item from cart
const removeItem = useCallback((id: string) => {
setItems(currentItems => currentItems.filter(item => item.id !== id));
}, [setItems]);
// Update item quantity
const updateQuantity = useCallback((id: string, quantity: number) => {
if (quantity <= 0) {
removeItem(id);
return;
}
setItems(currentItems =>
currentItems.map(item =>
item.id === id ? { ...item, quantity } : item
)
);
}, [setItems, removeItem]);
// Clear entire cart
const clearCart = useCallback(() => {
setItems([]);
}, [setItems]);
// Check if item is in cart
const isInCart = useCallback((id: string) => {
return items.some(item => item.id === id);
}, [items]);
// Get specific item
const getItem = useCallback((id: string) => {
return items.find(item => item.id === id);
}, [items]);
return {
items,
totalItems,
totalPrice,
addItem,
removeItem,
updateQuantity,
clearCart,
isInCart,
getItem
};
};
Using the Shopping Cart Hook
// components/ShoppingCart.tsx
import React from 'react';
import { useShoppingCart } from '../hooks/useShoppingCart';
const ShoppingCart: React.FC = () => {
const {
items,
totalItems,
totalPrice,
addItem,
removeItem,
updateQuantity,
clearCart
} = useShoppingCart();
const sampleProducts = [
{ id: '1', name: 'MacBook Pro', price: 1299, image: '/macbook.jpg' },
{ id: '2', name: 'iPhone 15', price: 899, image: '/iphone.jpg' },
{ id: '3', name: 'AirPods Pro', price: 249, image: '/airpods.jpg' },
];
return (
{/* Cart Header */}
Shopping Cart
{totalItems} items
${totalPrice.toFixed(2)}
{/* Product List */}
{sampleProducts.map(product => {
const cartItem = items.find(item => item.id === product.id);
return (
{product.name}
${product.price}
{cartItem ? (
{cartItem.quantity}
) : (
)}
);
})}
{/* Clear Cart */}
{items.length > 0 && (
)}
);
};
Hook Composition and Best Practices
1. Combining Multiple Hooks
Create powerful compositions by combining simpler hooks:
// hooks/useSearchWithHistory.ts
import { useState, useCallback } from 'react';
import { useDebounce } from './useDebounce';
import { useLocalStorage } from './useLocalStorage';
import { useFetch } from './useFetch';
interface SearchResult {
id: string;
title: string;
description: string;
}
export const useSearchWithHistory = (apiEndpoint: string) => {
const [query, setQuery] = useState('');
const [searchHistory, setSearchHistory] = useLocalStorage('search-history', []);
// Debounce the search query
const debouncedQuery = useDebounce(query, 500);
// Fetch search results
const { data: results, loading, error } = useFetch(
debouncedQuery ? `${apiEndpoint}?q=${encodeURIComponent(debouncedQuery)}` : '',
{ enabled: !!debouncedQuery }
);
// Add to search history
const addToHistory = useCallback((searchTerm: string) => {
if (searchTerm.trim() && !searchHistory.includes(searchTerm)) {
setSearchHistory(prev => [searchTerm, ...prev].slice(0, 10)); // Keep last 10 searches
}
}, [searchHistory, setSearchHistory]);
// Search function
const search = useCallback((searchTerm: string) => {
setQuery(searchTerm);
if (searchTerm.trim()) {
addToHistory(searchTerm);
}
}, [addToHistory]);
// Clear history
const clearHistory = useCallback(() => {
setSearchHistory([]);
}, [setSearchHistory]);
return {
query,
setQuery,
search,
results: results || [],
loading,
error,
searchHistory,
clearHistory
};
};
2. Testing Custom Hooks
Custom hooks are easy to test with React Testing Library:
// __tests__/useCounter.test.ts
import { renderHook, act } from '@testing-library/react';
import { useCounter } from '../hooks/useCounter';
describe('useCounter', () => {
it('should initialize with default value', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
});
it('should increment count', () => {
const { result } = renderHook(() => useCounter({ step: 5 }));
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(5);
});
it('should respect max value', () => {
const { result } = renderHook(() => useCounter({ max: 10 }));
act(() => {
result.current.setValue(15);
});
expect(result.current.count).toBe(10);
});
});
Performance Considerations
1. Memoization
Use useMemo and useCallback to prevent unnecessary recalculations and re-renders:
// ❌ Bad: Function recreated on every render
const addItem = (item) => {
setItems(prev => [...prev, item]);
};
// ✅ Good: Function memoized
const addItem = useCallback((item) => {
setItems(prev => [...prev, item]);
}, []);
2. Cleanup
Always clean up side effects to prevent memory leaks:
useEffect(() => {
const controller = new AbortController();
fetchData(controller.signal);
return () => {
controller.abort(); // Cleanup
};
}, []);
Common Patterns and Tips
1. Return Objects vs Arrays
- Return arrays when the order matters and you want array destructuring:
const [value, setValue] = useState() - Return objects when you have multiple named values:
const { data, loading, error } = useFetch()
2. TypeScript Best Practices
- Always define proper interfaces for your hook parameters and return values
- Use generic types when your hook works with different data types
- Provide default values for optional parameters
3. Naming Conventions
- Always prefix custom hooks with "use"
- Use descriptive names that explain what the hook does
- Follow established patterns:
useXxx,useXxxState,useXxxEffect
Wrapping Up
Custom hooks are incredibly powerful tools that can transform your React development experience. They provide:
- ✅ Code Reusability: Share logic across multiple components
- ✅ Separation of Concerns: Keep components focused on rendering
- ✅ Easier Testing: Test logic in isolation from components
- ✅ Better Organization: Group related stateful logic together
- ✅ Type Safety: Full TypeScript support with proper interfaces
- ✅ Performance: Optimize with memoization and proper cleanup
Start by identifying repeated patterns in your components, then extract them into custom hooks. Begin with simple hooks like useToggle or useLocalStorage, then gradually build more complex ones as you gain confidence.
The hooks we've built today - from the simple counter to the complex shopping cart - demonstrate how custom hooks can encapsulate everything from basic state management to complex business logic. They make your code more maintainable, testable, and reusable across your entire application.
Remember: the best custom hook is one that solves a real problem in your codebase and makes your components cleaner and more focused on their primary responsibility - rendering UI.
