React Hooks Mastery
Master essential and advanced React hooks for state and side effects
React Hooks Mastery
Master React Hooks with free flashcards and spaced repetition practice. This lesson covers useState, useEffect, custom hooks, performance optimization with useMemo and useCallback, and advanced patternsβessential concepts for building modern, efficient React applications.
Welcome to React Hooks π£
React Hooks revolutionized how we write React components. Introduced in React 16.8, Hooks allow you to use state and other React features without writing class components. They provide a more direct API to the React concepts you already know: props, state, context, refs, and lifecycle.
In this comprehensive lesson, you'll learn to leverage Hooks effectively, understand their rules and constraints, avoid common pitfalls, and build custom Hooks to encapsulate reusable logic. By the end, you'll write cleaner, more maintainable React code with confidence.
Core Concepts
What Are Hooks? πͺ
Hooks are functions that let you "hook into" React state and lifecycle features from function components. They don't work inside classesβthey let you use React without classes.
Key characteristics:
- Functions that start with
use(naming convention) - Can only be called at the top level of components
- Can only be called from React function components or custom Hooks
- Enable state and side effects in functional components
π‘ Tip: Think of Hooks as a way to "plug in" React features to your functional components, like plugging appliances into electrical outlets.
The Two Rules of Hooks βοΈ
React enforces two critical rules:
π Rules of Hooks
| Rule | Description | Why? |
|---|---|---|
| 1. Top Level Only | Don't call Hooks inside loops, conditions, or nested functions | React relies on call order to preserve state between renders |
| 2. React Functions Only | Only call Hooks from React function components or custom Hooks | Ensures Hooks are traceable and manageable by React |
β οΈ Common Mistake: Calling Hooks conditionally breaks React's internal state tracking:
// β WRONG - Hook inside condition
if (someCondition) {
const [state, setState] = useState(0);
}
// β
RIGHT - Condition inside Hook logic
const [state, setState] = useState(0);
if (someCondition) {
setState(1);
}
useState: Managing Component State π
useState is the most fundamental Hook. It adds state to functional components.
Syntax:
const [state, setState] = useState(initialValue);
state: Current state valuesetState: Function to update stateinitialValue: Initial state (can be a value or a function)
Example with primitive value:
import React, { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>
Increment
</button>
</div>
);
}
Functional updates: When new state depends on previous state, use a function:
// β Risky - can miss updates in rapid succession
setCount(count + 1);
// β
Safe - always uses latest state
setCount(prevCount => prevCount + 1);
Lazy initialization: For expensive initial computations:
// β Runs on every render
const [data, setData] = useState(expensiveComputation());
// β
Runs only on mount
const [data, setData] = useState(() => expensiveComputation());
π§ Mnemonic: "State Updates Safely Every time" - use functional updates (prevState => newState) for safety.
useEffect: Side Effects and Lifecycle π
useEffect lets you perform side effects in function components. It serves the same purpose as componentDidMount, componentDidUpdate, and componentWillUnmount combined.
Syntax:
useEffect(() => {
// Side effect code here
return () => {
// Cleanup code (optional)
};
}, [dependencies]);
Three patterns of useEffect:
| Pattern | Dependencies | When It Runs | Use Case |
|---|---|---|---|
| No deps array | useEffect(() => {...}) | After every render | Rarely needed - usually too frequent |
| Empty deps | useEffect(() => {...}, []) | Once on mount | Data fetching, subscriptions |
| With deps | useEffect(() => {...}, [a, b]) | When a or b changes | Sync with specific values |
Data fetching example:
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
async function fetchUser() {
setLoading(true);
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
if (!cancelled) {
setUser(data);
setLoading(false);
}
}
fetchUser();
// Cleanup function prevents state updates after unmount
return () => {
cancelled = true;
};
}, [userId]); // Re-run when userId changes
if (loading) return <div>Loading...</div>;
return <div>{user.name}</div>;
}
Subscription example with cleanup:
function ChatRoom({ roomId }) {
const [messages, setMessages] = useState([]);
useEffect(() => {
const socket = connectToRoom(roomId);
socket.on('message', (msg) => {
setMessages(prev => [...prev, msg]);
});
// Cleanup: disconnect when component unmounts or roomId changes
return () => {
socket.disconnect();
};
}, [roomId]);
return <div>{messages.map(m => <p key={m.id}>{m.text}</p>)}</div>;
}
π‘ Tip: Think of useEffect as "synchronizing" your component with external systems (APIs, subscriptions, DOM).
β οΈ Common Mistake: Missing dependencies causes stale closures:
// β WRONG - count is stale
useEffect(() => {
const timer = setInterval(() => {
setCount(count + 1); // Always uses count from first render
}, 1000);
return () => clearInterval(timer);
}, []); // Missing 'count' dependency
// β
RIGHT - use functional update
useEffect(() => {
const timer = setInterval(() => {
setCount(c => c + 1); // Always gets latest count
}, 1000);
return () => clearInterval(timer);
}, []); // No dependencies needed
useContext: Consuming Context π
useContext provides a cleaner way to consume React Context compared to Context.Consumer.
Syntax:
const value = useContext(MyContext);
Example:
const ThemeContext = React.createContext('light');
function App() {
return (
<ThemeContext.Provider value="dark">
<Toolbar />
</ThemeContext.Provider>
);
}
function Toolbar() {
return <ThemedButton />;
}
function ThemedButton() {
const theme = useContext(ThemeContext);
return <button className={theme}>Click me</button>;
}
Multiple contexts:
function Component() {
const theme = useContext(ThemeContext);
const user = useContext(UserContext);
const locale = useContext(LocaleContext);
return <div className={theme}>{user.name} - {locale}</div>;
}
useRef: Persisting Values and DOM Access π―
useRef creates a mutable reference that persists across renders without causing re-renders when changed.
Two main use cases:
1. Accessing DOM elements:
function TextInputWithFocus() {
const inputRef = useRef(null);
const focusInput = () => {
inputRef.current.focus();
};
return (
<>
<input ref={inputRef} type="text" />
<button onClick={focusInput}>Focus Input</button>
</>
);
}
2. Storing mutable values:
function Timer() {
const [seconds, setSeconds] = useState(0);
const intervalRef = useRef(null);
const start = () => {
if (!intervalRef.current) {
intervalRef.current = setInterval(() => {
setSeconds(s => s + 1);
}, 1000);
}
};
const stop = () => {
clearInterval(intervalRef.current);
intervalRef.current = null;
};
return (
<div>
<p>{seconds}s</p>
<button onClick={start}>Start</button>
<button onClick={stop}>Stop</button>
</div>
);
}
π Key difference: Changing ref.current doesn't trigger re-renders, unlike setState.
useMemo: Memoizing Expensive Computations β‘
useMemo caches the result of expensive calculations, recomputing only when dependencies change.
Syntax:
const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);
When to use useMemo:
- Expensive computations (filtering large arrays, complex calculations)
- Preventing unnecessary child re-renders (when passing objects/arrays as props)
- NOT for cheap operations (premature optimization)
Example:
function ProductList({ products, filterText }) {
// Without useMemo: filters on every render (even unrelated state changes)
// With useMemo: filters only when products or filterText changes
const filteredProducts = useMemo(() => {
console.log('Filtering products...');
return products.filter(p =>
p.name.toLowerCase().includes(filterText.toLowerCase())
);
}, [products, filterText]);
return (
<ul>
{filteredProducts.map(p => <li key={p.id}>{p.name}</li>)}
</ul>
);
}
Preventing object recreation:
function Parent() {
const [count, setCount] = useState(0);
// β New object every render - Child re-renders unnecessarily
const config = { theme: 'dark', locale: 'en' };
// β
Same object reference when dependencies don't change
const config = useMemo(() => ({
theme: 'dark',
locale: 'en'
}), []);
return <Child config={config} />;
}
β οΈ Don't overuse: useMemo has overhead. Only use for genuinely expensive operations.
useCallback: Memoizing Functions π
useCallback returns a memoized version of a callback function, changing only when dependencies change.
Syntax:
const memoizedCallback = useCallback(() => {
doSomething(a, b);
}, [a, b]);
Primary use case: Preventing unnecessary re-renders of child components that depend on callback props.
Example:
function Parent() {
const [count, setCount] = useState(0);
const [otherState, setOtherState] = useState(0);
// β New function every render - Child re-renders even if count unchanged
const handleClick = () => {
console.log(count);
};
// β
Same function reference when count unchanged
const handleClick = useCallback(() => {
console.log(count);
}, [count]);
return <Child onClick={handleClick} />;
}
const Child = React.memo(({ onClick }) => {
console.log('Child rendered');
return <button onClick={onClick}>Click</button>;
});
π§ Remember: useCallback(fn, deps) is equivalent to useMemo(() => fn, deps)
useCallback vs useMemo quick comparison:
| Hook | Returns | Use Case |
|---|---|---|
useMemo | Memoized value | Expensive calculations, object/array creation |
useCallback | Memoized function | Passing callbacks to optimized children |
Custom Hooks: Reusable Logic π οΈ
Custom Hooks let you extract component logic into reusable functions. They're regular JavaScript functions that can call other Hooks.
Naming convention: Must start with use (e.g., useWindowSize, useFetch)
Example 1: Window size Hook:
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 Component() {
const { width, height } = useWindowSize();
return <div>Window is {width}x{height}</div>;
}
Example 2: Data fetching Hook:
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() {
try {
setLoading(true);
const response = await fetch(url);
if (!response.ok) throw new Error('Failed to fetch');
const json = await response.json();
if (!cancelled) {
setData(json);
setError(null);
}
} catch (err) {
if (!cancelled) {
setError(err.message);
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
fetchData();
return () => {
cancelled = true;
};
}, [url]);
return { data, loading, error };
}
// Usage
function UserProfile({ userId }) {
const { data: user, loading, error } = useFetch(`/api/users/${userId}`);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return <div>{user.name}</div>;
}
Example 3: Local storage Hook:
function useLocalStorage(key, initialValue) {
const [storedValue, setStoredValue] = useState(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.error(error);
return initialValue;
}
});
const setValue = (value) => {
try {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
window.localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (error) {
console.error(error);
}
};
return [storedValue, setValue];
}
// Usage
function Settings() {
const [theme, setTheme] = useLocalStorage('theme', 'light');
return (
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
Current: {theme}
</button>
);
}
π‘ Custom Hook Benefits:
- Encapsulate complex logic
- Reuse across components
- Easier to test in isolation
- Share stateful logic without render props or HOCs
useReducer: Complex State Management π
useReducer is an alternative to useState for complex state logic involving multiple sub-values or when next state depends on previous state.
Syntax:
const [state, dispatch] = useReducer(reducer, initialState);
When to use useReducer:
- Complex state logic with multiple sub-values
- State transitions depend on previous state
- Need to pass dispatch down instead of callbacks
- State updates involve multiple operations
Example: Todo list:
const initialState = { todos: [], filter: 'all' };
function reducer(state, action) {
switch (action.type) {
case 'ADD_TODO':
return {
...state,
todos: [...state.todos, { id: Date.now(), text: action.text, done: false }]
};
case 'TOGGLE_TODO':
return {
...state,
todos: state.todos.map(todo =>
todo.id === action.id ? { ...todo, done: !todo.done } : todo
)
};
case 'DELETE_TODO':
return {
...state,
todos: state.todos.filter(todo => todo.id !== action.id)
};
case 'SET_FILTER':
return { ...state, filter: action.filter };
default:
return state;
}
}
function TodoApp() {
const [state, dispatch] = useReducer(reducer, initialState);
const [input, setInput] = useState('');
const handleAdd = () => {
if (input.trim()) {
dispatch({ type: 'ADD_TODO', text: input });
setInput('');
}
};
const filteredTodos = state.todos.filter(todo => {
if (state.filter === 'active') return !todo.done;
if (state.filter === 'completed') return todo.done;
return true;
});
return (
<div>
<input value={input} onChange={e => setInput(e.target.value)} />
<button onClick={handleAdd}>Add</button>
<div>
<button onClick={() => dispatch({ type: 'SET_FILTER', filter: 'all' })}>All</button>
<button onClick={() => dispatch({ type: 'SET_FILTER', filter: 'active' })}>Active</button>
<button onClick={() => dispatch({ type: 'SET_FILTER', filter: 'completed' })}>Completed</button>
</div>
<ul>
{filteredTodos.map(todo => (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.done}
onChange={() => dispatch({ type: 'TOGGLE_TODO', id: todo.id })}
/>
<span style={{ textDecoration: todo.done ? 'line-through' : 'none' }}>
{todo.text}
</span>
<button onClick={() => dispatch({ type: 'DELETE_TODO', id: todo.id })}>Delete</button>
</li>
))}
</ul>
</div>
);
}
π§ Mental model: Think of useReducer like Redux for a single componentβactions describe "what happened," reducer specifies "how state changes."
Detailed Examples
Example 1: Authentication Flow with Multiple Hooks π
This example combines useState, useEffect, useContext, and a custom Hook to build a complete authentication system:
// AuthContext.js
import React, { createContext, useContext, useState, useEffect } from 'react';
const AuthContext = createContext(null);
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within AuthProvider');
}
return context;
}
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Check for stored token on mount
const token = localStorage.getItem('token');
if (token) {
fetchUserData(token)
.then(userData => {
setUser(userData);
setLoading(false);
})
.catch(() => {
localStorage.removeItem('token');
setLoading(false);
});
} else {
setLoading(false);
}
}, []);
const login = async (email, password) => {
const response = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
});
if (!response.ok) throw new Error('Login failed');
const { token, user } = await response.json();
localStorage.setItem('token', token);
setUser(user);
};
const logout = () => {
localStorage.removeItem('token');
setUser(null);
};
const value = { user, loading, login, logout };
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
// App.js
function App() {
return (
<AuthProvider>
<Router>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/dashboard" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
</Routes>
</Router>
</AuthProvider>
);
}
// ProtectedRoute.js
function ProtectedRoute({ children }) {
const { user, loading } = useAuth();
const navigate = useNavigate();
useEffect(() => {
if (!loading && !user) {
navigate('/login');
}
}, [user, loading, navigate]);
if (loading) return <div>Loading...</div>;
return user ? children : null;
}
// LoginPage.js
function LoginPage() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const { login } = useAuth();
const navigate = useNavigate();
const handleSubmit = async (e) => {
e.preventDefault();
try {
await login(email, password);
navigate('/dashboard');
} catch (err) {
setError(err.message);
}
};
return (
<form onSubmit={handleSubmit}>
{error && <div className="error">{error}</div>}
<input
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
placeholder="Email"
/>
<input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
placeholder="Password"
/>
<button type="submit">Login</button>
</form>
);
}
Key patterns demonstrated:
- Custom Hook (
useAuth) encapsulates authentication logic - Context provides auth state globally
- Protected routes redirect unauthenticated users
- Persistent login via localStorage
- Loading states during async operations
Example 2: Real-time Data with WebSockets π‘
Building a custom Hook for WebSocket connections with automatic reconnection:
function useWebSocket(url, options = {}) {
const [data, setData] = useState(null);
const [status, setStatus] = useState('connecting');
const wsRef = useRef(null);
const reconnectTimeoutRef = useRef(null);
const { reconnectInterval = 3000, maxReconnectAttempts = 5 } = options;
const reconnectAttemptsRef = useRef(0);
const connect = useCallback(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) return;
setStatus('connecting');
const ws = new WebSocket(url);
ws.onopen = () => {
setStatus('connected');
reconnectAttemptsRef.current = 0;
};
ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
setData(message);
} catch (err) {
console.error('Failed to parse message:', err);
}
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
setStatus('error');
};
ws.onclose = () => {
setStatus('disconnected');
wsRef.current = null;
// Attempt reconnection
if (reconnectAttemptsRef.current < maxReconnectAttempts) {
reconnectAttemptsRef.current++;
reconnectTimeoutRef.current = setTimeout(() => {
connect();
}, reconnectInterval);
} else {
setStatus('failed');
}
};
wsRef.current = ws;
}, [url, reconnectInterval, maxReconnectAttempts]);
const send = useCallback((message) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify(message));
} else {
console.warn('WebSocket not connected');
}
}, []);
const disconnect = useCallback(() => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
}, []);
useEffect(() => {
connect();
return () => {
disconnect();
};
}, [connect, disconnect]);
return { data, status, send, disconnect, reconnect: connect };
}
// Usage
function LiveChat({ roomId }) {
const { data: message, status, send } = useWebSocket(`wss://api.example.com/chat/${roomId}`);
const [messages, setMessages] = useState([]);
const [input, setInput] = useState('');
useEffect(() => {
if (message) {
setMessages(prev => [...prev, message]);
}
}, [message]);
const handleSend = () => {
if (input.trim()) {
send({ type: 'message', text: input });
setInput('');
}
};
return (
<div>
<div className="status">Status: {status}</div>
<div className="messages">
{messages.map((msg, i) => (
<div key={i}>{msg.user}: {msg.text}</div>
))}
</div>
<input
value={input}
onChange={e => setInput(e.target.value)}
onKeyPress={e => e.key === 'Enter' && handleSend()}
disabled={status !== 'connected'}
/>
<button onClick={handleSend} disabled={status !== 'connected'}>
Send
</button>
</div>
);
}
Advanced patterns:
- useRef stores WebSocket instance without causing re-renders
- useCallback prevents function recreation
- Automatic reconnection with exponential backoff
- Cleanup prevents memory leaks
- Status tracking for UI feedback
Example 3: Optimized List with Virtualization π
Using useMemo and useCallback to optimize a large list component:
function VirtualizedList({ items, itemHeight, containerHeight }) {
const [scrollTop, setScrollTop] = useState(0);
const containerRef = useRef(null);
// Calculate visible range
const { startIndex, endIndex, totalHeight } = useMemo(() => {
const startIndex = Math.floor(scrollTop / itemHeight);
const endIndex = Math.min(
items.length - 1,
Math.ceil((scrollTop + containerHeight) / itemHeight)
);
const totalHeight = items.length * itemHeight;
return { startIndex, endIndex, totalHeight };
}, [scrollTop, itemHeight, containerHeight, items.length]);
// Slice visible items
const visibleItems = useMemo(() => {
return items.slice(startIndex, endIndex + 1);
}, [items, startIndex, endIndex]);
// Memoize scroll handler
const handleScroll = useCallback((e) => {
setScrollTop(e.target.scrollTop);
}, []);
// Calculate offset for positioning
const offsetY = startIndex * itemHeight;
return (
<div
ref={containerRef}
onScroll={handleScroll}
style={{
height: containerHeight,
overflow: 'auto',
position: 'relative'
}}
>
<div style={{ height: totalHeight }}>
<div
style={{
transform: `translateY(${offsetY}px)`,
position: 'absolute',
top: 0,
left: 0,
right: 0
}}
>
{visibleItems.map((item, index) => (
<VirtualizedItem
key={startIndex + index}
item={item}
height={itemHeight}
/>
))}
</div>
</div>
</div>
);
}
const VirtualizedItem = React.memo(({ item, height }) => {
return (
<div style={{ height, borderBottom: '1px solid #ccc', padding: '8px' }}>
{item.title}
</div>
);
});
// Usage
function App() {
const items = useMemo(() => {
return Array.from({ length: 10000 }, (_, i) => ({
id: i,
title: `Item ${i + 1}`
}));
}, []);
return (
<VirtualizedList
items={items}
itemHeight={50}
containerHeight={600}
/>
);
}
Performance optimizations:
- Only renders visible items (windowing)
- useMemo prevents recalculation of visible range
- React.memo prevents unnecessary child re-renders
- useCallback stabilizes event handler reference
- Transform for GPU-accelerated positioning
Example 4: Form Validation with Custom Hook β
A reusable form validation Hook:
function useForm(initialValues, validationRules) {
const [values, setValues] = useState(initialValues);
const [errors, setErrors] = useState({});
const [touched, setTouched] = useState({});
const [isSubmitting, setIsSubmitting] = useState(false);
// Validate single field
const validateField = useCallback((name, value) => {
const rules = validationRules[name];
if (!rules) return null;
for (const rule of rules) {
const error = rule(value, values);
if (error) return error;
}
return null;
}, [validationRules, values]);
// Validate all fields
const validateForm = useCallback(() => {
const newErrors = {};
let isValid = true;
Object.keys(validationRules).forEach(name => {
const error = validateField(name, values[name]);
if (error) {
newErrors[name] = error;
isValid = false;
}
});
setErrors(newErrors);
return isValid;
}, [validateField, validationRules, values]);
const handleChange = useCallback((name, value) => {
setValues(prev => ({ ...prev, [name]: value }));
// Validate on change if already touched
if (touched[name]) {
const error = validateField(name, value);
setErrors(prev => ({
...prev,
[name]: error || undefined
}));
}
}, [touched, validateField]);
const handleBlur = useCallback((name) => {
setTouched(prev => ({ ...prev, [name]: true }));
const error = validateField(name, values[name]);
setErrors(prev => ({
...prev,
[name]: error || undefined
}));
}, [validateField, values]);
const handleSubmit = useCallback(async (onSubmit) => {
// Mark all fields as touched
const allTouched = Object.keys(validationRules).reduce(
(acc, key) => ({ ...acc, [key]: true }),
{}
);
setTouched(allTouched);
if (validateForm()) {
setIsSubmitting(true);
try {
await onSubmit(values);
} finally {
setIsSubmitting(false);
}
}
}, [validateForm, validationRules, values]);
const reset = useCallback(() => {
setValues(initialValues);
setErrors({});
setTouched({});
setIsSubmitting(false);
}, [initialValues]);
return {
values,
errors,
touched,
isSubmitting,
handleChange,
handleBlur,
handleSubmit,
reset
};
}
// Validation rules
const required = (value) => {
return value ? null : 'This field is required';
};
const minLength = (min) => (value) => {
return value.length >= min ? null : `Must be at least ${min} characters`;
};
const email = (value) => {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) ? null : 'Invalid email';
};
const matchField = (fieldName) => (value, allValues) => {
return value === allValues[fieldName] ? null : 'Fields do not match';
};
// Usage
function SignupForm() {
const form = useForm(
{ email: '', password: '', confirmPassword: '' },
{
email: [required, email],
password: [required, minLength(8)],
confirmPassword: [required, matchField('password')]
}
);
const onSubmit = async (values) => {
await fetch('/api/signup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(values)
});
};
return (
<form onSubmit={(e) => {
e.preventDefault();
form.handleSubmit(onSubmit);
}}>
<div>
<input
type="email"
value={form.values.email}
onChange={(e) => form.handleChange('email', e.target.value)}
onBlur={() => form.handleBlur('email')}
/>
{form.touched.email && form.errors.email && (
<span className="error">{form.errors.email}</span>
)}
</div>
<div>
<input
type="password"
value={form.values.password}
onChange={(e) => form.handleChange('password', e.target.value)}
onBlur={() => form.handleBlur('password')}
/>
{form.touched.password && form.errors.password && (
<span className="error">{form.errors.password}</span>
)}
</div>
<div>
<input
type="password"
value={form.values.confirmPassword}
onChange={(e) => form.handleChange('confirmPassword', e.target.value)}
onBlur={() => form.handleBlur('confirmPassword')}
/>
{form.touched.confirmPassword && form.errors.confirmPassword && (
<span className="error">{form.errors.confirmPassword}</span>
)}
</div>
<button type="submit" disabled={form.isSubmitting}>
{form.isSubmitting ? 'Submitting...' : 'Sign Up'}
</button>
</form>
);
}
Features demonstrated:
- Reusable validation logic
- Touch tracking (only show errors after user interaction)
- Real-time validation after first blur
- Composable validation rules
- Submit state management
- Form reset capability
Common Mistakes to Avoid β οΈ
1. Calling Hooks Conditionally
// β WRONG - Hook call order changes
if (condition) {
const [state, setState] = useState(0);
}
// β
RIGHT - Condition inside Hook logic
const [state, setState] = useState(0);
if (condition) {
// Use state here
}
2. Missing useEffect Dependencies
// β WRONG - Stale closure
useEffect(() => {
console.log(count); // Always logs initial count
}, []); // Missing 'count' dependency
// β
RIGHT - Include all dependencies
useEffect(() => {
console.log(count);
}, [count]);
3. Not Cleaning Up useEffect
// β WRONG - Memory leak
useEffect(() => {
const timer = setInterval(() => {
console.log('tick');
}, 1000);
// No cleanup!
}, []);
// β
RIGHT - Clean up on unmount
useEffect(() => {
const timer = setInterval(() => {
console.log('tick');
}, 1000);
return () => clearInterval(timer);
}, []);
4. Overusing useMemo/useCallback
// β WRONG - Premature optimization
const sum = useMemo(() => a + b, [a, b]); // Simple calculation doesn't need memoization
// β
RIGHT - Only for expensive operations
const filtered = useMemo(() => {
return hugeArray.filter(complexPredicate);
}, [hugeArray]);
5. Mutating State Directly
// β WRONG - Direct mutation
const [items, setItems] = useState([]);
items.push(newItem); // Doesn't trigger re-render!
// β
RIGHT - Create new array
setItems([...items, newItem]);
// or
setItems(prev => [...prev, newItem]);
6. Setting State During Render
// β WRONG - Infinite loop
function Component() {
const [count, setCount] = useState(0);
setCount(count + 1); // Called every render!
return <div>{count}</div>;
}
// β
RIGHT - Set state in event handlers or effects
function Component() {
const [count, setCount] = useState(0);
useEffect(() => {
setCount(1);
}, []);
return <div>{count}</div>;
}
7. Forgetting Functional Updates
// β WRONG - Can miss updates
setCount(count + 1);
setCount(count + 1); // Still only increments by 1!
// β
RIGHT - Use functional form
setCount(c => c + 1);
setCount(c => c + 1); // Correctly increments by 2
8. Using Index as Key in Lists
// β WRONG - Causes bugs with reordering
items.map((item, index) => <div key={index}>{item}</div>)
// β
RIGHT - Use stable unique identifier
items.map(item => <div key={item.id}>{item}</div>)
Key Takeaways π―
π React Hooks Quick Reference
| Hook | Purpose | Key Point |
|---|---|---|
useState | Add state to components | Use functional updates for prev-dependent logic |
useEffect | Handle side effects | Always clean up subscriptions/timers |
useContext | Consume context | Cleaner than Context.Consumer |
useRef | Persist values/DOM refs | Doesn't trigger re-renders |
useMemo | Memoize values | Only for expensive computations |
useCallback | Memoize functions | Prevent child re-renders |
useReducer | Complex state logic | Alternative to useState for complex state |
| Custom Hooks | Reusable logic | Must start with 'use' |
Golden Rules:
- β Always call Hooks at the top level
- β Only call Hooks from React functions
- β Include all dependencies in useEffect
- β Clean up effects that need it
- β Use functional updates when next state depends on previous
- β Extract reusable logic into custom Hooks
- β Memoize only when necessary
- β Keep components focused and simple
Performance Tips:
- π Use React.memo for expensive child components
- π Split state to avoid unnecessary re-renders
- π Use useCallback for callbacks passed to memoized children
- π Use useMemo for expensive calculations
- π Consider code splitting for large components
Testing Hooks:
- Use
@testing-library/react-hooksfor isolated Hook testing - Test custom Hooks separately from components
- Mock external dependencies (APIs, timers)
- Test cleanup functions
- Verify correct dependency arrays
π Further Study
- React Official Hooks Documentation: https://react.dev/reference/react - Comprehensive guide with all built-in Hooks
- useHooks Collection: https://usehooks.com/ - Curated collection of useful custom Hooks with examples
- React Hooks Testing Library: https://github.com/testing-library/react-hooks-testing-library - Best practices for testing Hooks
π Congratulations! You've mastered React Hooks. You now understand how to manage state, handle side effects, optimize performance, and create reusable custom Hooks. Practice these patterns in your projects, and you'll write cleaner, more maintainable React code. Remember: Hooks are just functionsβdon't overthink them, but do respect their rules!