Why Your Component Re-Renders 47 Times When You Expected Once
When React introduced hooks in version 16.8, it fundamentally changed how we write React components. Yet three years later, I still see developers struggling with the same patterns - infinite re-renders, stale closures, and performance bottlenecks that could be avoided with proper hook usage.
The problem isn't that hooks are difficult. The problem is that most tutorials teach you what hooks do, not how to use them effectively in real applications. You learn useState
returns state and a setter, but nobody explains why your component re-renders 47 times when you expected it to render once.
This guide bridges that gap. Instead of basic syntax, we'll explore the patterns that separate beginner React developers from those who build production applications that scale. You'll learn how to avoid the most common pitfalls, optimize performance from day one, and create custom hooks that actually make your code more maintainable.
By the end, you'll understand not just how hooks work, but how to use them to build applications that perform well and remain manageable as they grow.
Understanding React hooks fundamentals
React hooks are functions that let you "hook into" React state and lifecycle features from functional components. But this simple definition misses the crucial insight: hooks follow specific rules that determine when your components re-render and how they maintain state between renders.
The key concept most developers miss is that every render creates a new function execution context. This means every variable, function, and hook call exists independently in each render. Understanding this principle is essential for avoiding the most common hook-related bugs.
The render cycle and hook behavior
When a component renders, React calls your component function from top to bottom. Each hook call happens in the same order every time, which is why you can't call hooks conditionally. React uses this consistent ordering to maintain state between renders.
function UserProfile({ userId }) {
// First render: useState creates initial state
// Subsequent renders: useState returns current state
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
// This happens on every render, but React
// only runs the effect when dependencies change
useEffect(() => {
fetchUser(userId).then(userData => {
setUser(userData);
setLoading(false);
});
}, [userId]); // Dependencies array controls when effect runs
if (loading) return <div>Loading user...</div>;
return <div>Welcome, {user.name}!</div>;
}
Each time this component renders, React creates a new execution context where user
, loading
, setUser
, and setLoading
exist as separate variables. React maintains the actual state values internally and provides them to your component through the hook calls.
Mastering useState patterns and optimization
The useState
hook seems simple, but using it effectively requires understanding several key patterns that impact both functionality and performance.
State initialization and lazy initialization
The most basic useState pattern initializes state with a simple value:
const [count, setCount] = useState(0);
const [name, setName] = useState('');
However, when your initial state requires computation, you should use lazy initialization to avoid running expensive calculations on every render:
// ❌ Bad: Runs expensive calculation on every render
const [expensiveValue, setExpensiveValue] = useState(
calculateExpensiveValue()
);
// ✅ Good: Runs calculation only once
const [expensiveValue, setExpensiveValue] = useState(() =>
calculateExpensiveValue()
);
The lazy initialization pattern is particularly useful when reading from localStorage or performing calculations based on props:
function ShoppingCart({ initialItems }) {
// This only runs once, not on every render
const [items, setItems] = useState(() => {
const saved = localStorage.getItem('cartItems');
return saved ? JSON.parse(saved) : initialItems;
});
return (
<div>
{items.map(item => (
<CartItem key={item.id} item={item} />
))}
</div>
);
}
Functional state updates
When your new state depends on the previous state, always use the functional update pattern. This prevents bugs caused by stale state values in closures:
function Counter() {
const [count, setCount] = useState(0);
const handleIncrement = () => {
// ❌ Bad: Can cause lost updates in rapid succession
setCount(count + 1);
// ✅ Good: Always gets the most recent state
setCount(prevCount => prevCount + 1);
};
const handleMultipleIncrements = () => {
// With functional updates, these all work correctly
setCount(prev => prev + 1);
setCount(prev => prev + 1);
setCount(prev => prev + 1);
// count will be incremented by 3
};
return (
<div>
<p>Count: {count}</p>
<button onClick={handleIncrement}>Increment</button>
<button onClick={handleMultipleIncrements}>+3</button>
</div>
);
}
Complex state management patterns
For complex state objects, consider whether multiple useState calls or a single useReducer would be more appropriate. Multiple useState calls work well when state pieces are independent:
function UserProfile() {
const [personalInfo, setPersonalInfo] = useState({
name: '',
email: '',
bio: ''
});
const [preferences, setPreferences] = useState({
theme: 'light',
notifications: true
});
const [loading, setLoading] = useState(false);
const [errors, setErrors] = useState({});
const updatePersonalInfo = (field, value) => {
setPersonalInfo(prev => ({
...prev,
[field]: value
}));
};
return (
<form>
<input
value={personalInfo.name}
onChange={(e) => updatePersonalInfo('name', e.target.value)}
placeholder="Name"
/>
<input
value={personalInfo.email}
onChange={(e) => updatePersonalInfo('email', e.target.value)}
placeholder="Email"
/>
</form>
);
}
Advanced useEffect patterns and cleanup
The useEffect
hook handles side effects, but using it correctly requires understanding dependency arrays, cleanup functions, and when effects actually run.
Dependency array patterns
The dependency array controls when your effect runs. Understanding these patterns prevents infinite re-renders and ensures your effects run at the right times:
function DataFetcher({ userId, filters }) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
// Runs on every render - usually wrong
useEffect(() => {
fetchData().then(setData);
});
// Runs once on mount - good for initial setup
useEffect(() => {
initializeAnalytics();
}, []);
// Runs when userId changes - good for data fetching
useEffect(() => {
setLoading(true);
fetchUserData(userId)
.then(setData)
.finally(() => setLoading(false));
}, [userId]);
// Runs when any filter changes
useEffect(() => {
if (data) {
const filtered = applyFilters(data, filters);
setData(filtered);
}
}, [filters]); // Be careful: this might not include all dependencies
}
The ESLint plugin react-hooks/exhaustive-deps
helps catch missing dependencies, but understanding why dependencies matter is crucial for writing correct effects.
Cleanup functions and preventing memory leaks
Always clean up subscriptions, timers, and async operations to prevent memory leaks and state updates on unmounted components:
function LiveNotifications() {
const [notifications, setNotifications] = useState([]);
useEffect(() => {
let isSubscribed = true;
const subscription = notificationService.subscribe(notification => {
// Only update state if component is still mounted
if (isSubscribed) {
setNotifications(prev => [notification, ...prev]);
}
});
// Cleanup function runs when effect is cleaned up
return () => {
isSubscribed = false;
subscription.unsubscribe();
};
}, []);
return (
<div>
{notifications.map(notification => (
<NotificationItem key={notification.id} {...notification} />
))}
</div>
);
}
For timer-based effects, proper cleanup prevents timers from continuing after component unmount:
function AutoSaveForm({ data, onSave }) {
useEffect(() => {
const timer = setInterval(() => {
onSave(data);
}, 30000); // Auto-save every 30 seconds
return () => clearInterval(timer);
}, [data, onSave]);
}
Handling async operations in useEffect
Never make the useEffect callback async directly. Instead, define async functions inside the effect:
function UserData({ userId }) {
const [user, setUser] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
// ❌ Don't do this
// useEffect(async () => { ... }, []);
// ✅ Define async function inside effect
async function fetchUser() {
try {
setError(null);
const userData = await api.getUser(userId);
setUser(userData);
} catch (err) {
setError(err.message);
}
}
fetchUser();
}, [userId]);
if (error) return <div>Error: {error}</div>;
if (!user) return <div>Loading...</div>;
return <UserProfile user={user} />;
}
Creating powerful custom hooks
Custom hooks are where React's compositional power really shines. They let you extract stateful logic into reusable functions that multiple components can share.
Building a data fetching hook
A well-designed custom hook encapsulates complex logic and provides a clean API:
function useApi(url, options = {}) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
async function fetchData() {
try {
setLoading(true);
setError(null);
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`HTTP error! status: ${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, JSON.stringify(options)]);
const refetch = useCallback(() => {
setLoading(true);
setError(null);
// Trigger useEffect by changing a dependency
}, [url, options]);
return { data, loading, error, refetch };
}
// Usage in components becomes much cleaner
function UserProfile({ userId }) {
const { data: user, loading, error } = useApi(`/api/users/${userId}`);
if (loading) return <div>Loading user...</div>;
if (error) return <div>Error: {error}</div>;
return <div>Welcome, {user.name}!</div>;
}
Local storage synchronization hook
This custom hook demonstrates how to synchronize state with localStorage while handling edge cases:
function useLocalStorage(key, initialValue) {
// Get value from localStorage or use initial value
const [storedValue, setStoredValue] = useState(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.warn(`Error reading localStorage key "${key}":`, error);
return initialValue;
}
});
// Return a wrapped version of useState's setter that persists to localStorage
const 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;
setStoredValue(valueToStore);
window.localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (error) {
console.warn(`Error setting localStorage key "${key}":`, error);
}
}, [key, storedValue]);
return [storedValue, setValue];
}
// Usage
function Settings() {
const [theme, setTheme] = useLocalStorage('theme', 'light');
const [autoSave, setAutoSave] = useLocalStorage('autoSave', true);
return (
<div>
<select value={theme} onChange={(e) => setTheme(e.target.value)}>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
<label>
<input
type="checkbox"
checked={autoSave}
onChange={(e) => setAutoSave(e.target.checked)}
/>
Auto-save changes
</label>
</div>
);
}
Form management hook
A custom hook for form handling reduces boilerplate and centralizes validation logic:
function useForm(initialValues, validate) {
const [values, setValues] = useState(initialValues);
const [errors, setErrors] = useState({});
const [touched, setTouched] = useState({});
const [isSubmitting, setIsSubmitting] = useState(false);
const handleChange = useCallback((name, value) => {
setValues(prev => ({ ...prev, [name]: value }));
// Clear error when user starts typing
if (errors[name]) {
setErrors(prev => ({ ...prev, [name]: '' }));
}
}, [errors]);
const handleBlur = useCallback((name) => {
setTouched(prev => ({ ...prev, [name]: true }));
if (validate) {
const fieldErrors = validate({ ...values, [name]: values[name] });
setErrors(prev => ({ ...prev, [name]: fieldErrors[name] }));
}
}, [values, validate]);
const handleSubmit = useCallback(async (onSubmit) => {
setIsSubmitting(true);
if (validate) {
const validationErrors = validate(values);
setErrors(validationErrors);
if (Object.keys(validationErrors).length > 0) {
setIsSubmitting(false);
return;
}
}
try {
await onSubmit(values);
} catch (error) {
setErrors({ submit: error.message });
} finally {
setIsSubmitting(false);
}
}, [values, validate]);
const reset = useCallback(() => {
setValues(initialValues);
setErrors({});
setTouched({});
setIsSubmitting(false);
}, [initialValues]);
return {
values,
errors,
touched,
isSubmitting,
handleChange,
handleBlur,
handleSubmit,
reset
};
}
Performance optimization strategies
React hooks can impact performance if not used carefully. Understanding these optimization techniques helps you build applications that remain responsive as they scale.
Preventing unnecessary re-renders with useMemo
Use useMemo
for expensive calculations that don't need to run on every render:
function ExpensiveComponent({ items, filters }) {
// This calculation only runs when items or filters change
const filteredItems = useMemo(() => {
return items.filter(item => {
return filters.every(filter => filter.test(item));
});
}, [items, filters]);
// This component only re-renders when necessary
const itemComponents = useMemo(() => {
return filteredItems.map(item => (
<ExpensiveItemComponent key={item.id} item={item} />
));
}, [filteredItems]);
return <div>{itemComponents}</div>;
}
Optimizing function references with useCallback
Use useCallback
to prevent child components from re-rendering when parent functions haven't actually changed:
function TodoList({ todos, onToggle, onDelete }) {
const [filter, setFilter] = useState('all');
// Without useCallback, these functions change on every render
// causing all TodoItem components to re-render unnecessarily
const handleToggle = useCallback((id) => {
onToggle(id);
}, [onToggle]);
const handleDelete = useCallback((id) => {
onDelete(id);
}, [onDelete]);
const filteredTodos = useMemo(() => {
switch (filter) {
case 'active':
return todos.filter(todo => !todo.completed);
case 'completed':
return todos.filter(todo => todo.completed);
default:
return todos;
}
}, [todos, filter]);
return (
<div>
<FilterButtons filter={filter} onFilterChange={setFilter} />
{filteredTodos.map(todo => (
<TodoItem
key={todo.id}
todo={todo}
onToggle={handleToggle}
onDelete={handleDelete}
/>
))}
</div>
);
}
// Wrap child components in React.memo to prevent re-renders
// when props haven't changed
const TodoItem = React.memo(({ todo, onToggle, onDelete }) => {
return (
<div>
<input
type="checkbox"
checked={todo.completed}
onChange={() => onToggle(todo.id)}
/>
<span>{todo.text}</span>
<button onClick={() => onDelete(todo.id)}>Delete</button>
</div>
);
});
State structure optimization
How you structure state affects performance. Keep related state together and split unrelated state:
// ❌ Bad: One large state object causes unnecessary re-renders
function Dashboard() {
const [state, setState] = useState({
user: null,
notifications: [],
sidebar: { open: false, width: 300 },
theme: 'light',
lastActivity: Date.now()
});
// Updating lastActivity re-renders entire component
const updateActivity = () => {
setState(prev => ({ ...prev, lastActivity: Date.now() }));
};
}
// ✅ Good: Split state by update frequency and relationships
function Dashboard() {
const [user, setUser] = useState(null);
const [notifications, setNotifications] = useState([]);
const [sidebarConfig, setSidebarConfig] = useState({ open: false, width: 300 });
const [theme, setTheme] = useState('light');
// Activity tracking doesn't affect main UI state
const lastActivityRef = useRef(Date.now());
const updateActivity = () => {
lastActivityRef.current = Date.now();
};
}
Testing React hooks effectively
Testing hooks requires understanding how to simulate the React environment and test both state changes and side effects.
Testing custom hooks with React Testing Library
Use the renderHook
utility to test custom hooks in isolation:
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';
describe('useCounter', () => {
test('should initialize with provided value', () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
test('should increment count', () => {
const { result } = renderHook(() => useCounter(0));
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
test('should reset to initial value', () => {
const { result } = renderHook(() => useCounter(5));
act(() => {
result.current.increment();
result.current.increment();
});
expect(result.current.count).toBe(7);
act(() => {
result.current.reset();
});
expect(result.current.count).toBe(5);
});
});
Testing hooks with async operations
For hooks that perform async operations, use async testing patterns:
import { renderHook, waitFor } from '@testing-library/react';
import { useApi } from './useApi';
// Mock fetch
global.fetch = jest.fn();
describe('useApi', () => {
afterEach(() => {
jest.resetAllMocks();
});
test('should fetch data successfully', async () => {
const mockData = { id: 1, name: 'Test User' };
fetch.mockResolvedValueOnce({
ok: true,
json: async () => mockData
});
const { result } = renderHook(() => useApi('/api/user/1'));
// Initially loading
expect(result.current.loading).toBe(true);
expect(result.current.data).toBe(null);
// Wait for async operation to complete
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toEqual(mockData);
expect(result.current.error).toBe(null);
});
test('should handle fetch errors', async () => {
fetch.mockRejectedValueOnce(new Error('Network error'));
const { result } = renderHook(() => useApi('/api/user/1'));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.error).toBe('Network error');
expect(result.current.data).toBe(null);
});
});
Testing components that use hooks
When testing components that use hooks, focus on the behavior rather than implementation details:
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import UserProfile from './UserProfile';
// Mock the API
jest.mock('./api', () => ({
getUser: jest.fn()
}));
describe('UserProfile', () => {
test('should display loading state initially', () => {
render(<UserProfile userId="123" />);
expect(screen.getByText('Loading user...')).toBeInTheDocument();
});
test('should display user data when loaded', async () => {
const mockUser = { id: '123', name: 'John Doe', email: 'john@example.com' };
require('./api').getUser.mockResolvedValueOnce(mockUser);
render(<UserProfile userId="123" />);
await waitFor(() => {
expect(screen.queryByText('Loading user...')).not.toBeInTheDocument();
});
expect(screen.getByText('John Doe')).toBeInTheDocument();
expect(screen.getByText('john@example.com')).toBeInTheDocument();
});
});
Common pitfalls and debugging techniques
Understanding common hook pitfalls helps you write more reliable code and debug issues faster.
The stale closure problem
One of the most common issues with hooks is stale closures, where functions capture old values:
function Timer() {
const [count, setCount] = useState(0);
useEffect(() => {
// ❌ This captures the initial value of count (0)
const timer = setInterval(() => {
console.log('Count:', count); // Always logs 0
setCount(count + 1); // Always adds 1 to 0
}, 1000);
return () => clearInterval(timer);
}, []); // Empty dependency array causes stale closure
return <div>Count: {count}</div>;
}
// ✅ Fix 1: Include count in dependencies
function Timer() {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
console.log('Count:', count); // Always current value
setCount(count + 1);
}, 1000);
return () => clearInterval(timer);
}, [count]); // Timer restarts on every count change
return <div>Count: {count}</div>;
}
// ✅ Fix 2: Use functional state updates
function Timer() {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
setCount(prevCount => {
console.log('Count:', prevCount); // Always current value
return prevCount + 1;
});
}, 1000);
return () => clearInterval(timer);
}, []); // Empty dependency array is safe now
return <div>Count: {count}</div>;
}
Infinite re-render loops
Infinite re-renders usually occur when dependencies change on every render:
// ❌ Creates infinite loop
function UserList({ filters }) {
const [users, setUsers] = useState([]);
useEffect(() => {
fetchUsers(filters).then(setUsers);
}, [filters]); // If filters is a new object every render, infinite loop
return users.map(user => <UserCard key={user.id} user={user} />);
}
// ✅ Stabilize object dependencies
function UserList({ filters }) {
const [users, setUsers] = useState([]);
// Memoize the filters object
const stableFilters = useMemo(() => filters, [
filters.search,
filters.category,
filters.sortBy
]);
useEffect(() => {
fetchUsers(stableFilters).then(setUsers);
}, [stableFilters]);
return users.map(user => <UserCard key={user.id} user={user} />);
}
Debugging hook behavior
Use React DevTools and console logging strategically to understand hook behavior:
function DebugHook() {
const [count, setCount] = useState(0);
const [name, setName] = useState('');
// Log renders to understand when and why component re-renders
console.log('Component rendered with:', { count, name });
useEffect(() => {
console.log('Effect ran, count changed to:', count);
}, [count]);
useEffect(() => {
console.log('Effect ran, name changed to:', name);
}, [name]);
// Custom hook for debugging re-renders
const useWhyDidYouUpdate = (name, props) => {
const previous = useRef();
useEffect(() => {
if (previous.current) {
const changedProps = Object.entries(props).reduce((ps, [k, v]) => {
if (previous.current[k] !== v) {
ps[k] = [previous.current[k], v];
}
return ps;
}, {});
if (Object.keys(changedProps).length > 0) {
console.log('[why-did-you-update]', name, changedProps);
}
}
previous.current = props;
});
};
useWhyDidYouUpdate('DebugHook', { count, name });
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(c => c + 1)}>Increment</button>
<input value={name} onChange={(e) => setName(e.target.value)} />
</div>
);
}
Advanced patterns and real-world applications
As your React applications grow more complex, these advanced patterns help you maintain clean, performant code.
Compound components with hooks
Create flexible component APIs using compound patterns:
// Context for sharing state between compound components
const TabsContext = createContext();
function Tabs({ children, defaultTab = 0 }) {
const [activeTab, setActiveTab] = useState(defaultTab);
return (
<TabsContext.Provider value={{ activeTab, setActiveTab }}>
<div className="tabs">{children}</div>
</TabsContext.Provider>
);
}
function TabList({ children }) {
return <div className="tab-list">{children}</div>;
}
function Tab({ index, children }) {
const { activeTab, setActiveTab } = useContext(TabsContext);
const isActive = activeTab === index;
return (
<button
className={`tab ${isActive ? 'active' : ''}`}
onClick={() => setActiveTab(index)}
>
{children}
</button>
);
}
function TabPanels({ children }) {
return <div className="tab-panels">{children}</div>;
}
function TabPanel({ index, children }) {
const { activeTab } = useContext(TabsContext);
if (activeTab !== index) return null;
return <div className="tab-panel">{children}</div>;
}
// Compound API usage
function App() {
return (
<Tabs defaultTab={0}>
<TabList>
<Tab index={0}>Profile</Tab>
<Tab index={1}>Settings</Tab>
<Tab index={2}>Billing</Tab>
</TabList>
<TabPanels>
<TabPanel index={0}>
<UserProfile />
</TabPanel>
<TabPanel index={1}>
<UserSettings />
</TabPanel>
<TabPanel index={2}>
<BillingInfo />
</TabPanel>
</TabPanels>
</Tabs>
);
}
State machines with useReducer
For complex state logic, useReducer with state machine patterns provides better predictability:
const authReducer = (state, action) => {
switch (state.status) {
case 'idle':
switch (action.type) {
case 'LOGIN_START':
return { status: 'loading', error: null };
default:
return state;
}
case 'loading':
switch (action.type) {
case 'LOGIN_SUCCESS':
return { status: 'authenticated', user: action.user, error: null };
case 'LOGIN_ERROR':
return { status: 'idle', error: action.error };
default:
return state;
}
case 'authenticated':
switch (action.type) {
case 'LOGOUT':
return { status: 'idle', user: null, error: null };
default:
return state;
}
default:
return state;
}
};
function useAuth() {
const [state, dispatch] = useReducer(authReducer, {
status: 'idle',
user: null,
error: null
});
const login = useCallback(async (credentials) => {
dispatch({ type: 'LOGIN_START' });
try {
const user = await authService.login(credentials);
dispatch({ type: 'LOGIN_SUCCESS', user });
} catch (error) {
dispatch({ type: 'LOGIN_ERROR', error: error.message });
}
}, []);
const logout = useCallback(() => {
dispatch({ type: 'LOGOUT' });
authService.logout();
}, []);
return {
...state,
login,
logout,
isLoading: state.status === 'loading',
isAuthenticated: state.status === 'authenticated'
};
}
If you're building complex React applications and need help implementing these patterns effectively, we specialize in creating performant React architectures that scale with your business needs.
Performance monitoring and optimization
Building performant React applications requires ongoing monitoring and optimization. Here's how to implement systematic performance tracking:
React DevTools Profiler integration
Use the Profiler API to measure component performance in production:
import { Profiler } from 'react';
function onRenderCallback(id, phase, actualDuration, baseDuration, startTime, commitTime) {
// Send performance data to analytics
analytics.track('react_component_render', {
componentId: id,
phase, // 'mount' or 'update'
actualDuration, // Time spent rendering
baseDuration, // Estimated time without memoization
startTime,
commitTime
});
}
function App() {
return (
<Profiler id="App" onRender={onRenderCallback}>
<Header />
<MainContent />
<Footer />
</Profiler>
);
}
Custom performance hooks
Create hooks to monitor specific performance metrics:
function useRenderCount() {
const renderCount = useRef(0);
useEffect(() => {
renderCount.current += 1;
});
return renderCount.current;
}
function usePerformanceMonitor(componentName) {
const renderCount = useRenderCount();
const startTime = useRef(performance.now());
useEffect(() => {
const renderTime = performance.now() - startTime.current;
if (renderTime > 16) { // Slower than 60fps
console.warn(`${componentName} slow render:`, {
renderTime,
renderCount
});
}
startTime.current = performance.now();
});
return { renderCount };
}
// Usage
function ExpensiveComponent() {
const { renderCount } = usePerformanceMonitor('ExpensiveComponent');
// Component logic...
return <div>Render #{renderCount}</div>;
}
Memory leak detection
Implement hooks to detect and prevent common memory leaks:
function useMemoryLeakDetection() {
const subscriptions = useRef(new Set());
const timers = useRef(new Set());
const addSubscription = useCallback((subscription) => {
subscriptions.current.add(subscription);
return () => subscriptions.current.delete(subscription);
}, []);
const addTimer = useCallback((timerId) => {
timers.current.add(timerId);
return () => {
clearTimeout(timerId);
timers.current.delete(timerId);
};
}, []);
useEffect(() => {
return () => {
// Cleanup all subscriptions
subscriptions.current.forEach(sub => {
if (sub && typeof sub.unsubscribe === 'function') {
sub.unsubscribe();
}
});
// Clear all timers
timers.current.forEach(timerId => clearTimeout(timerId));
console.log('Cleaned up:', {
subscriptions: subscriptions.current.size,
timers: timers.current.size
});
};
}, []);
return { addSubscription, addTimer };
}
Building production-ready applications
When moving from development to production, these patterns ensure your React hooks perform well under real-world conditions.
Error boundaries for hook errors
While hooks can't directly use error boundaries, you can create patterns that handle errors gracefully:
function useErrorHandler() {
const [error, setError] = useState(null);
const resetError = useCallback(() => setError(null), []);
const handleError = useCallback((error) => {
console.error('Hook error:', error);
setError(error);
// Report to error tracking service
errorTracking.report(error);
}, []);
// Wrap async operations to catch errors
const handleAsync = useCallback(async (asyncOperation) => {
try {
setError(null);
return await asyncOperation();
} catch (error) {
handleError(error);
throw error;
}
}, [handleError]);
return { error, resetError, handleError, handleAsync };
}
// Usage with data fetching
function useApiWithErrorHandling(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const { error, handleAsync } = useErrorHandler();
useEffect(() => {
handleAsync(async () => {
setLoading(true);
const response = await fetch(url);
const result = await response.json();
setData(result);
}).finally(() => {
setLoading(false);
});
}, [url, handleAsync]);
return { data, loading, error };
}
Concurrent features preparation
Prepare your hooks for React's concurrent features:
function useDeferredValue(value) {
// Use React's useDeferredValue when available
if (typeof React.useDeferredValue !== 'undefined') {
return React.useDeferredValue(value);
}
// Fallback for older React versions
const [deferredValue, setDeferredValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => {
setDeferredValue(value);
}, 0);
return () => clearTimeout(timer);
}, [value]);
return deferredValue;
}
function SearchResults({ query }) {
const deferredQuery = useDeferredValue(query);
const { data: results } = useApi(`/search?q=${deferredQuery}`);
return (
<div>
{results?.map(result => (
<SearchResult key={result.id} result={result} />
))}
</div>
);
}
Hook composition patterns
Create flexible, composable hook patterns for complex applications:
function useCompositeState(initialState) {
const [state, setState] = useState(initialState);
const [history, setHistory] = useState([initialState]);
const [historyIndex, setHistoryIndex] = useState(0);
const updateState = useCallback((newState) => {
const nextState = typeof newState === 'function'
? newState(state)
: newState;
setState(nextState);
// Add to history
const newHistory = history.slice(0, historyIndex + 1);
newHistory.push(nextState);
setHistory(newHistory);
setHistoryIndex(newHistory.length - 1);
}, [state, history, historyIndex]);
const undo = useCallback(() => {
if (historyIndex > 0) {
const prevIndex = historyIndex - 1;
setHistoryIndex(prevIndex);
setState(history[prevIndex]);
}
}, [history, historyIndex]);
const redo = useCallback(() => {
if (historyIndex < history.length - 1) {
const nextIndex = historyIndex + 1;
setHistoryIndex(nextIndex);
setState(history[nextIndex]);
}
}, [history, historyIndex]);
return {
state,
updateState,
undo,
redo,
canUndo: historyIndex > 0,
canRedo: historyIndex < history.length - 1
};
}
// Usage in a rich text editor
function TextEditor() {
const {
state: content,
updateState: setContent,
undo,
redo,
canUndo,
canRedo
} = useCompositeState('');
return (
<div>
<div>
<button onClick={undo} disabled={!canUndo}>Undo</button>
<button onClick={redo} disabled={!canRedo}>Redo</button>
</div>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
/>
</div>
);
}
Mastering React hooks for scalable applications
React hooks fundamentally changed how we build React applications, but their true power emerges when you understand the patterns that separate beginner implementations from production-ready code. The techniques we've covered - from preventing stale closures to building reusable custom hooks - form the foundation of maintainable React applications.
The key insight is that hooks are not just about state management; they're about creating composable, testable, and performant abstractions that scale with your application's complexity. Whether you're optimizing re-renders with useMemo
and useCallback
, building sophisticated custom hooks for data fetching, or implementing error handling patterns, the principles remain consistent: understand the render cycle, manage dependencies carefully, and always consider performance implications.
As React continues evolving with features like concurrent rendering and Suspense, the hook patterns you've learned here will serve as building blocks for even more powerful applications. The investment in understanding these fundamentals pays dividends as your applications grow from simple prototypes to complex, user-facing products.
Ready to implement these React hook patterns in your next project? Contact us to discuss how we can help you build scalable React applications that perform well and remain maintainable as they grow.