React Hooks Mastery: Beyond useState and useEffect
React Hooks Mastery: Beyond useState and useEffect
React Hooks revolutionized how we write React components, but many developers only scratch the surface. Let's explore advanced patterns and optimization techniques that will elevate your React applications.
Understanding Hook Dependencies
The foundation of effective hook usage lies in understanding dependencies. React's built-in hooks like useEffect, useCallback, and useMemo rely on dependency arrays to determine when to re-run.
The Dependency Array Deep Dive
// ❌ Missing dependencies - potential bugs
useEffect(() => {
fetchUserData(userId, apiKey);
}, []); // userId and apiKey should be in dependencies
// ✅ Correct dependencies
useEffect(() => {
fetchUserData(userId, apiKey);
}, [userId, apiKey]);
// ✅ Alternative with exhaustive-deps ESLint rule
useEffect(() => {
const fetchData = async () => {
const data = await fetchUserData(userId, apiKey);
setUserData(data);
};
fetchData();
}, [userId, apiKey]); // ESLint will warn if you miss dependencies
Stable References with useCallback
useCallback memoizes function references, preventing unnecessary re-renders of child components:
const UserProfile = ({ userId, onUserUpdate }) => {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(false);
// ❌ New function on every render
const handleUpdateUser = (userData) => {
setLoading(true);
updateUser(userId, userData)
.then((updatedUser) => {
setUser(updatedUser);
onUserUpdate(updatedUser);
})
.finally(() => setLoading(false));
};
// ✅ Memoized function reference
const handleUpdateUser = useCallback((userData) => {
setLoading(true);
updateUser(userId, userData)
.then((updatedUser) => {
setUser(updatedUser);
onUserUpdate(updatedUser);
})
.finally(() => setLoading(false));
}, [userId, onUserUpdate]);
return (
<UserForm
user={user}
loading={loading}
onSubmit={handleUpdateUser} // Stable reference prevents UserForm re-renders
/>
);
};
Advanced Custom Hooks
Custom hooks are where React's composability truly shines. Let's build some powerful, reusable hooks.
useLocalStorage Hook
function useLocalStorage(key, initialValue) {
// Get value from localStorage or use initialValue
const [storedValue, setStoredValue] = useState(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.error(`Error reading localStorage key "${key}":`, error);
return initialValue;
}
});
// Memoized setter function
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.error(`Error setting localStorage key "${key}":`, error);
}
}, [key, storedValue]);
// Listen for changes in other tabs/windows
useEffect(() => {
const handleStorageChange = (e) => {
if (e.key === key && e.newValue) {
setStoredValue(JSON.parse(e.newValue));
}
};
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, [key]);
return [storedValue, setValue];
}
// Usage
const UserSettings = () => {
const [theme, setTheme] = useLocalStorage('theme', 'light');
const [preferences, setPreferences] = useLocalStorage('userPrefs', {});
return (
<div>
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
Toggle Theme ({theme})
</button>
</div>
);
};
useAsync Hook for Data Fetching
function useAsync(asyncFunction, dependencies = []) {
const [state, setState] = useState({
data: null,
loading: true,
error: null
});
const execute = useCallback(async (...args) => {
setState(prev => ({ ...prev, loading: true, error: null }));
try {
const data = await asyncFunction(...args);
setState({ data, loading: false, error: null });
return data;
} catch (error) {
setState({ data: null, loading: false, error });
throw error;
}
}, dependencies);
useEffect(() => {
execute();
}, [execute]);
return {
...state,
execute
};
}
// Usage
const UserProfile = ({ userId }) => {
const {
data: user,
loading,
error,
execute: refetchUser
} = useAsync(
() => fetchUser(userId),
[userId]
);
if (loading) return <Spinner />;
if (error) return <ErrorMessage error={error} onRetry={refetchUser} />;
if (!user) return <div>No user found</div>;
return <UserCard user={user} />;
};
Performance Optimization Patterns
Smart Memoization with useMemo
useMemo should be used judiciously. Here's when and how to use it effectively:
const ProductList = ({ products, filters, sortBy }) => {
// ✅ Expensive computation that depends on props
const filteredAndSortedProducts = useMemo(() => {
console.log('Filtering and sorting products...'); // This should run sparingly
return products
.filter(product => {
return Object.entries(filters).every(([key, value]) =>
!value || product[key] === value
);
})
.sort((a, b) => {
switch (sortBy) {
case 'price':
return a.price - b.price;
case 'name':
return a.name.localeCompare(b.name);
case 'rating':
return b.rating - a.rating;
default:
return 0;
}
});
}, [products, filters, sortBy]);
// ✅ Memoize expensive derived data
const productStats = useMemo(() => ({
total: filteredAndSortedProducts.length,
avgPrice: filteredAndSortedProducts.reduce((sum, p) => sum + p.price, 0) / filteredAndSortedProducts.length,
categories: [...new Set(filteredAndSortedProducts.map(p => p.category))]
}), [filteredAndSortedProducts]);
return (
<div>
<ProductStats stats={productStats} />
<div className="product-grid">
{filteredAndSortedProducts.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
</div>
);
};
Optimizing Context Usage
Context can cause performance issues if not used carefully:
// ✅ Split contexts by update frequency
const UserContext = createContext();
const UserActionsContext = createContext();
function UserProvider({ children }) {
const [user, setUser] = useState(null);
// Memoize actions to prevent context consumers from re-rendering
const actions = useMemo(() => ({
login: async (credentials) => {
const userData = await login(credentials);
setUser(userData);
},
logout: () => {
logout();
setUser(null);
},
updateProfile: async (profileData) => {
const updatedUser = await updateUserProfile(user.id, profileData);
setUser(updatedUser);
}
}), [user?.id]); // Only recreate when user ID changes
return (
<UserContext.Provider value={user}>
<UserActionsContext.Provider value={actions}>
{children}
</UserActionsContext.Provider>
</UserContext.Provider>
);
}
// Custom hooks for consuming context
function useUser() {
const user = useContext(UserContext);
if (user === undefined) {
throw new Error('useUser must be used within a UserProvider');
}
return user;
}
function useUserActions() {
const actions = useContext(UserActionsContext);
if (actions === undefined) {
throw new Error('useUserActions must be used within a UserProvider');
}
return actions;
}
Advanced Hook Patterns
Compound Custom Hooks
Create hooks that work together for complex state management:
// Base hook for form state
function useFormState(initialValues) {
const [values, setValues] = useState(initialValues);
const [errors, setErrors] = useState({});
const [touched, setTouched] = useState({});
const setValue = useCallback((name, value) => {
setValues(prev => ({ ...prev, [name]: value }));
}, []);
const setError = useCallback((name, error) => {
setErrors(prev => ({ ...prev, [name]: error }));
}, []);
const setTouched = useCallback((name) => {
setTouched(prev => ({ ...prev, [name]: true }));
}, []);
const reset = useCallback(() => {
setValues(initialValues);
setErrors({});
setTouched({});
}, [initialValues]);
return {
values,
errors,
touched,
setValue,
setError,
setTouched,
reset
};
}
// Validation hook that extends form state
function useFormValidation(formState, validationSchema) {
const { values, setError } = formState;
const validate = useCallback(() => {
const newErrors = {};
let isValid = true;
Object.entries(validationSchema).forEach(([field, validators]) => {
const value = values[field];
for (const validator of validators) {
const error = validator(value, values);
if (error) {
newErrors[field] = error;
isValid = false;
break;
}
}
});
// Update errors for all fields at once
Object.keys(validationSchema).forEach(field => {
setError(field, newErrors[field] || null);
});
return isValid;
}, [values, validationSchema, setError]);
return { validate };
}
// Usage
const ContactForm = () => {
const formState = useFormState({
name: '',
email: '',
message: ''
});
const { validate } = useFormValidation(formState, {
name: [(value) => !value ? 'Name is required' : null],
email: [
(value) => !value ? 'Email is required' : null,
(value) => !/\S+@\S+\.\S+/.test(value) ? 'Invalid email format' : null
],
message: [
(value) => !value ? 'Message is required' : null,
(value) => value.length < 10 ? 'Message must be at least 10 characters' : null
]
});
const handleSubmit = async (e) => {
e.preventDefault();
if (!validate()) {
return;
}
try {
await submitContactForm(formState.values);
formState.reset();
} catch (error) {
console.error('Submission failed:', error);
}
};
return (
<form onSubmit={handleSubmit}>
<FormField
name="name"
placeholder="Your name"
formState={formState}
/>
<FormField
name="email"
type="email"
placeholder="your.email@example.com"
formState={formState}
/>
<FormField
name="message"
as="textarea"
placeholder="Your message..."
formState={formState}
/>
<button type="submit">Send Message</button>
</form>
);
};
Hook Testing Strategies
Testing custom hooks properly ensures they work in isolation and with components:
import { renderHook, act } from '@testing-library/react';
import { useLocalStorage } from '../hooks/useLocalStorage';
describe('useLocalStorage', () => {
beforeEach(() => {
localStorage.clear();
});
it('should initialize with initial value', () => {
const { result } = renderHook(() => useLocalStorage('test', 'initial'));
expect(result.current[0]).toBe('initial');
});
it('should update localStorage when value changes', () => {
const { result } = renderHook(() => useLocalStorage('test', 'initial'));
act(() => {
result.current[1]('updated');
});
expect(result.current[0]).toBe('updated');
expect(localStorage.getItem('test')).toBe('"updated"');
});
it('should handle localStorage errors gracefully', () => {
// Mock localStorage to throw an error
const setItemSpy = jest.spyOn(localStorage, 'setItem')
.mockImplementation(() => {
throw new Error('Quota exceeded');
});
const { result } = renderHook(() => useLocalStorage('test', 'initial'));
// Should not throw when setting value
expect(() => {
act(() => {
result.current[1]('new value');
});
}).not.toThrow();
setItemSpy.mockRestore();
});
});
Common Pitfalls and Solutions
Stale Closures
// ❌ Stale closure problem
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
setCount(count + 1); // This captures the initial count value (0)
}, 1000);
return () => clearInterval(timer);
}, []); // Empty dependency array means count is stale
return <div>{count}</div>;
}
// ✅ Solution 1: Use functional state updates
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
setCount(prevCount => prevCount + 1); // Always gets the latest count
}, 1000);
return () => clearInterval(timer);
}, []);
return <div>{count}</div>;
}
// ✅ Solution 2: Use useRef for mutable values
function Counter() {
const [count, setCount] = useState(0);
const countRef = useRef(count);
countRef.current = count; // Keep ref in sync
useEffect(() => {
const timer = setInterval(() => {
setCount(countRef.current + 1);
}, 1000);
return () => clearInterval(timer);
}, []);
return <div>{count}</div>;
}
Conclusion
Mastering React Hooks goes beyond knowing the basic APIs. It's about understanding dependencies, leveraging memoization effectively, creating reusable custom hooks, and avoiding common pitfalls.
The patterns shown here will help you build more performant, maintainable React applications. Remember: measure first, optimize second. Not every computation needs memoization, and not every callback needs useCallback.
Start with these patterns and adapt them to your specific use cases. Happy hooking!
About Allie
Developer and writer passionate about web technologies and creating user-friendly experiences.
Related Articles
Stay Updated
Subscribe to my newsletter to receive updates on new articles, tutorials, and resources.