What are React Hooks?
React Hooks are functions that let you use state and other React features in functional components. Introduced in React 16.8 (2019), hooks revolutionized how we write React components by eliminating the need for class components for stateful logic. Hooks allow you to reuse stateful logic across components without changing your component hierarchy.
Why Hooks?
- Simplify component logic – Write cleaner, more readable components
- Reuse stateful logic – Share logic across components without HOCs or render props
- Better code organization – Group related logic together
- Reduce bundle size – Functional components are smaller than classes
- Easier to learn – No need to understand 'this', binding, or classes
- Better performance – Less overhead than class components
- Future-proof – Hooks are the future of React development
Rules of Hooks
// ✅ CORRECT: Hooks called at top level function Component() { const [count, setCount] = useState(0); const [user, setUser] = useState(null); useEffect(() => { // Effect logic }, []); return <div>{count}</div>; } // ❌ WRONG: Hook called conditionally function Component() { if (condition) { const [count, setCount] = useState(0); // DON'T DO THIS! } } // ❌ WRONG: Hook called after early return function Component() { if (error) return null; const [count, setCount] = useState(0); // DON'T DO THIS! } // ❌ WRONG: Hook in nested function function Component() { function handleClick() { const [count, setCount] = useState(0); // DON'T DO THIS! } } // ✅ CORRECT: Custom hook following rules function useCustomHook() { const [data, setData] = useState(null); useEffect(() => { // Effect logic }, []); return data; }
useState – Managing Component State
useState is the most basic hook for adding state to functional components. It returns an array with two elements: the current state value and a function to update it.
import { useState } from 'react'; // Basic counter example function Counter() { const [count, setCount] = useState(0); return ( <div> <p>Count: {count}</p> <button onClick={() => setCount(count + 1)}>Increment</button> <button onClick={() => setCount(count - 1)}>Decrement</button> </div> ); } // Multiple state variables function UserProfile() { const [name, setName] = useState(''); const [age, setAge] = useState(0); const [email, setEmail] = useState(''); return ( <div> <input value={name} onChange={(e) => setName(e.target.value)} /> <input type="number" value={age} onChange={(e) => setAge(e.target.value)} /> <input value={email} onChange={(e) => setEmail(e.target.value)} /> </div> ); } // Using object state function Form() { const [form, setForm] = useState({ username: '', password: '', remember: false }); const handleChange = (e) => { const { name, value, type, checked } = e.target; setForm(prev => ({ ...prev, [name]: type === 'checkbox' ? checked : value })); }; return ( <form> <input name="username" value={form.username} onChange={handleChange} /> <input name="password" type="password" value={form.password} onChange={handleChange} /> <input name="remember" type="checkbox" checked={form.remember} onChange={handleChange} /> </form> ); } // Functional updates (when new state depends on previous) function CounterWithDelay() { const [count, setCount] = useState(0); const incrementLater = () => { setTimeout(() => { setCount(prevCount => prevCount + 1); // ✅ Uses previous value // setCount(count + 1); // ❌ Would use stale value }, 1000); }; return ( <div> <p>Count: {count}</p> <button onClick={incrementLater}>Increment after 1 second</button> </div> ); } // Lazy initialization (for expensive computations) function ExpensiveComponent() { const [state, setState] = useState(() => { const initialValue = expensiveComputation(); return initialValue; }); return <div>{state}</div>; } // useState with arrays function TodoList() { const [todos, setTodos] = useState([]); const [input, setInput] = useState(''); const addTodo = () => { setTodos(prev => [...prev, { id: Date.now(), text: input, completed: false }]); setInput(''); }; const toggleTodo = (id) => { setTodos(prev => prev.map(todo => todo.id === id ? { ...todo, completed: !todo.completed } : todo )); }; const removeTodo = (id) => { setTodos(prev => prev.filter(todo => todo.id !== id)); }; return ( <div> <input value={input} onChange={(e) => setInput(e.target.value)} /> <button onClick={addTodo}>Add Todo</button> <ul> {todos.map(todo => ( <li key={todo.id}> <span style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}> {todo.text} </span> <button onClick={() => toggleTodo(todo.id)}>Toggle</button> <button onClick={() => removeTodo(todo.id)}>Delete</button> </li> ))} </ul> </div> ); } export { Counter, UserProfile, Form, CounterWithDelay, TodoList };
useEffect – Handling Side Effects
useEffect lets you perform side effects in functional components. It replaces lifecycle methods (componentDidMount, componentDidUpdate, componentWillUnmount) from class components.
import { useState, useEffect } from 'react'; // Basic effect - runs after every render function BasicEffect() { const [count, setCount] = useState(0); useEffect(() => { document.title = `You clicked ${count} times`; }); // No dependency array - runs after every render return <button onClick={() => setCount(count + 1)}>Click me</button>; } // Empty dependency array - runs only once (componentDidMount) function DataFetcher() { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); useEffect(() => { fetch('https://api.example.com/data') .then(res => res.json()) .then(data => { setData(data); setLoading(false); }); }, []); // Empty array = run once if (loading) return <div>Loading...</div>; return <div>{JSON.stringify(data)}</div>; } // With dependencies - runs when dependencies change function UserProfile({ userId }) { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); useEffect(() => { setLoading(true); fetch(`https://api.example.com/users/${userId}`) .then(res => res.json()) .then(user => { setUser(user); setLoading(false); }); }, [userId]); // Re-run when userId changes if (loading) return <div>Loading user...</div>; return <div>{user?.name}</div>; } // Cleanup effect (componentWillUnmount) function Timer() { const [seconds, setSeconds] = useState(0); useEffect(() => { const interval = setInterval(() => { setSeconds(prev => prev + 1); }, 1000); // Cleanup function - runs before component unmounts return () => { clearInterval(interval); }; }, []); // Empty array = setup once, cleanup on unmount return <div>Timer: {seconds} seconds</div>; } // Effect with cleanup and dependencies function ChatRoom({ roomId }) { const [messages, setMessages] = useState([]); useEffect(() => { const connection = createConnection(roomId); connection.connect(); connection.on('message', (msg) => { setMessages(prev => [...prev, msg]); }); // Cleanup on unmount or before re-run return () => { connection.disconnect(); }; }, [roomId]); // Re-run when roomId changes return <div>{messages.length} messages</div>; } // Multiple effects - each for different purposes function ComplexComponent() { const [count, setCount] = useState(0); const [windowWidth, setWindowWidth] = useState(window.innerWidth); // Effect 1: Update document title useEffect(() => { document.title = `Count: ${count}`; }, [count]); // Effect 2: Track window resize useEffect(() => { const handleResize = () => setWindowWidth(window.innerWidth); window.addEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize); }, []); // Empty array = run once // Effect 3: Log count changes useEffect(() => { console.log(`Count changed to ${count}`); }, [count]); return ( <div> <p>Count: {count}</p> <p>Window width: {windowWidth}</p> <button onClick={() => setCount(count + 1)}>Increment</button> </div> ); } // Avoiding infinite loops function AvoidInfiniteLoop() { const [data, setData] = useState([]); const [filter, setFilter] = useState(''); // ❌ This would cause infinite loop // useEffect(() => { // setData(fetchData(filter)); // Updates state -> re-run effect // }, [data, filter]); // data changes -> effect runs again // ✅ Correct way useEffect(() => { fetchData(filter).then(setData); }, [filter]); // Only re-run when filter changes return <div>{/* render data */}</div>; } // Conditional effects function ConditionalEffect({ userId }) { const [user, setUser] = useState(null); useEffect(() => { if (!userId) return; // Skip if no userId let isMounted = true; // Flag to prevent state updates after unmount fetchUser(userId).then(user => { if (isMounted) { setUser(user); } }); return () => { isMounted = false; // Cleanup }; }, [userId]); return <div>{user?.name}</div>; } export { BasicEffect, DataFetcher, UserProfile, Timer, ChatRoom, ComplexComponent };
useContext – Managing Global State
useContext provides a way to pass data through the component tree without prop drilling. It works with React's Context API.
import { createContext, useContext, useState } from 'react'; // 1. Create context const ThemeContext = createContext(); const UserContext = createContext(); const LanguageContext = createContext(); // 2. Provider component function ThemeProvider({ children }) { const [theme, setTheme] = useState('light'); const toggleTheme = () => { setTheme(prev => prev === 'light' ? 'dark' : 'light'); }; return ( <ThemeContext.Provider value={{ theme, toggleTheme }}> {children} </ThemeContext.Provider> ); } // 3. Custom hook for consuming context function useTheme() { const context = useContext(ThemeContext); if (!context) { throw new Error('useTheme must be used within ThemeProvider'); } return context; } // 4. Components consuming context function ThemedButton() { const { theme, toggleTheme } = useTheme(); const styles = { light: { backgroundColor: '#fff', color: '#000', border: '1px solid #ccc' }, dark: { backgroundColor: '#333', color: '#fff', border: '1px solid #666' } }; return ( <button style={styles[theme]} onClick={toggleTheme}> Current theme: {theme} </button> ); } function ThemedPanel() { const { theme } = useTheme(); const styles = { light: { backgroundColor: '#f5f5f5', color: '#000', padding: '20px' }, dark: { backgroundColor: '#222', color: '#fff', padding: '20px' } }; return <div style={styles[theme]}>This panel uses the {theme} theme</div>; } // Multiple contexts function UserProvider({ children }) { const [user, setUser] = useState(null); return ( <UserContext.Provider value={{ user, setUser }}> {children} </UserContext.Provider> ); } function LanguageProvider({ children }) { const [language, setLanguage] = useState('en'); const translations = { en: { greeting: 'Hello', button: 'Click me' }, es: { greeting: 'Hola', button: 'Haz clic' }, fr: { greeting: 'Bonjour', button: 'Cliquez' } }; return ( <LanguageContext.Provider value={{ language, setLanguage, t: translations[language] }}> {children} </LanguageContext.Provider> ); } function useUser() { return useContext(UserContext); } function useLanguage() { return useContext(LanguageContext); } function Profile() { const { user } = useUser(); const { t } = useLanguage(); if (!user) return <div>Please log in</div>; return ( <div> <p>{t.greeting}, {user.name}!</p> </div> ); } function LanguageSelector() { const { language, setLanguage } = useLanguage(); return ( <select value={language} onChange={(e) => setLanguage(e.target.value)}> <option value="en">English</option> <option value="es">Español</option> <option value="fr">Français</option> </select> ); } // App with multiple providers function App() { return ( <ThemeProvider> <UserProvider> <LanguageProvider> <div> <LanguageSelector /> <ThemedButton /> <ThemedPanel /> <Profile /> </div> </LanguageProvider> </UserProvider> </ThemeProvider> ); } // Context with reducer pattern const TodoContext = createContext(); function todoReducer(state, action) { switch (action.type) { case 'ADD_TODO': return [...state, { id: Date.now(), text: action.payload, completed: false }]; case 'TOGGLE_TODO': return state.map(todo => todo.id === action.payload ? { ...todo, completed: !todo.completed } : todo ); case 'DELETE_TODO': return state.filter(todo => todo.id !== action.payload); default: return state; } } function TodoProvider({ children }) { const [todos, dispatch] = useReducer(todoReducer, []); return ( <TodoContext.Provider value={{ todos, dispatch }}> {children} </TodoContext.Provider> ); } function useTodos() { return useContext(TodoContext); } function TodoList() { const { todos, dispatch } = useTodos(); return ( <ul> {todos.map(todo => ( <li key={todo.id}> <span style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}> {todo.text} </span> <button onClick={() => dispatch({ type: 'TOGGLE_TODO', payload: todo.id })}> Toggle </button> <button onClick={() => dispatch({ type: 'DELETE_TODO', payload: todo.id })}> Delete </button> </li> ))} </ul> ); } export { ThemeProvider, useTheme, ThemedButton, ThemedPanel, UserProvider, LanguageProvider, useUser, useLanguage, Profile, LanguageSelector, TodoProvider, useTodos, TodoList, App };
useReducer – Complex State Logic
useReducer is an alternative to useState for complex state logic that involves multiple sub-values or when the next state depends on the previous state.
import { useReducer, useCallback } from 'react'; // Basic counter reducer function counterReducer(state, action) { switch (action.type) { case 'INCREMENT': return { count: state.count + 1 }; case 'DECREMENT': return { count: state.count - 1 }; case 'RESET': return { count: 0 }; case 'SET': return { count: action.payload }; default: return state; } } function Counter() { const [state, dispatch] = useReducer(counterReducer, { count: 0 }); return ( <div> <p>Count: {state.count}</p> <button onClick={() => dispatch({ type: 'INCREMENT' })}>+</button> <button onClick={() => dispatch({ type: 'DECREMENT' })}>-</button> <button onClick={() => dispatch({ type: 'RESET' })}>Reset</button> <button onClick={() => dispatch({ type: 'SET', payload: 10 })}>Set to 10</button> </div> ); } // Complex todo reducer const todoReducer = (state, action) => { switch (action.type) { case 'ADD_TODO': return { ...state, todos: [...state.todos, { id: Date.now(), text: action.payload, completed: false, createdAt: new Date() }] }; case 'TOGGLE_TODO': return { ...state, todos: state.todos.map(todo => todo.id === action.payload ? { ...todo, completed: !todo.completed } : todo ) }; case 'DELETE_TODO': return { ...state, todos: state.todos.filter(todo => todo.id !== action.payload) }; case 'EDIT_TODO': return { ...state, todos: state.todos.map(todo => todo.id === action.payload.id ? { ...todo, text: action.payload.text } : todo ) }; case 'CLEAR_COMPLETED': return { ...state, todos: state.todos.filter(todo => !todo.completed) }; case 'SET_FILTER': return { ...state, filter: action.payload }; default: return state; } }; function TodoApp() { const [state, dispatch] = useReducer(todoReducer, { todos: [], filter: 'all' }); const [input, setInput] = useState(''); const filteredTodos = state.todos.filter(todo => { if (state.filter === 'active') return !todo.completed; if (state.filter === 'completed') return todo.completed; return true; }); const addTodo = () => { if (input.trim()) { dispatch({ type: 'ADD_TODO', payload: input }); setInput(''); } }; return ( <div> <input value={input} onChange={(e) => setInput(e.target.value)} /> <button onClick={addTodo}>Add Todo</button> <div> <button onClick={() => dispatch({ type: 'SET_FILTER', payload: 'all' })}>All</button> <button onClick={() => dispatch({ type: 'SET_FILTER', payload: 'active' })}>Active</button> <button onClick={() => dispatch({ type: 'SET_FILTER', payload: 'completed' })}>Completed</button> <button onClick={() => dispatch({ type: 'CLEAR_COMPLETED' })}>Clear Completed</button> </div> <ul> {filteredTodos.map(todo => ( <li key={todo.id}> <input type="checkbox" checked={todo.completed} onChange={() => dispatch({ type: 'TOGGLE_TODO', payload: todo.id })} /> <span style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}> {todo.text} </span> <button onClick={() => dispatch({ type: 'DELETE_TODO', payload: todo.id })}> Delete </button> </li> ))} </ul> <p>{state.todos.filter(t => !t.completed).length} items left</p> </div> ); } // Form reducer example const formReducer = (state, action) => { switch (action.type) { case 'CHANGE': return { ...state, [action.field]: action.value, errors: { ...state.errors, [action.field]: validateField(action.field, action.value) } }; case 'TOUCH': return { ...state, touched: { ...state.touched, [action.field]: true } }; case 'SUBMIT': return { ...state, isSubmitting: true }; case 'SUCCESS': return { ...state, isSubmitting: false, submitted: true }; case 'ERROR': return { ...state, isSubmitting: false, submitError: action.error }; case 'RESET': return action.initialState; default: return state; } }; function validateField(field, value) { switch (field) { case 'email': return !value ? 'Email is required' : !/\S+@\S+\.\S+/.test(value) ? 'Invalid email' : ''; case 'password': return !value ? 'Password is required' : value.length < 6 ? 'Password must be at least 6 characters' : ''; default: return ''; } } function LoginForm() { const initialState = { email: '', password: '', errors: { email: '', password: '' }, touched: {}, isSubmitting: false, submitted: false, submitError: '' }; const [state, dispatch] = useReducer(formReducer, initialState); const handleChange = (field, value) => { dispatch({ type: 'CHANGE', field, value }); }; const handleBlur = (field) => { dispatch({ type: 'TOUCH', field }); }; const handleSubmit = async (e) => { e.preventDefault(); dispatch({ type: 'SUBMIT' }); try { await login(state.email, state.password); dispatch({ type: 'SUCCESS' }); } catch (error) { dispatch({ type: 'ERROR', error: error.message }); } }; if (state.submitted) { return <div>Login successful!</div>; } return ( <form onSubmit={handleSubmit}> <div> <input type="email" value={state.email} onChange={(e) => handleChange('email', e.target.value)} onBlur={() => handleBlur('email')} placeholder="Email" /> {state.touched.email && state.errors.email && <span style={{ color: 'red' }}>{state.errors.email}</span>} </div> <div> <input type="password" value={state.password} onChange={(e) => handleChange('password', e.target.value)} onBlur={() => handleBlur('password')} placeholder="Password" /> {state.touched.password && state.errors.password && <span style={{ color: 'red' }}>{state.errors.password}</span>} </div> {state.submitError && <div style={{ color: 'red' }}>{state.submitError}</div>} <button type="submit" disabled={state.isSubmitting}> {state.isSubmitting ? 'Logging in...' : 'Login'} </button> </form> ); } // useState vs useReducer comparison function CounterWithUseState() { const [count, setCount] = useState(0); return <button onClick={() => setCount(count + 1)}>{count}</button>; } function CounterWithUseReducer() { const [state, dispatch] = useReducer( (state, action) => ({ count: state.count + action }), { count: 0 } ); return <button onClick={() => dispatch(1)}>{state.count}</button>; } export { Counter, TodoApp, LoginForm, CounterWithUseState, CounterWithUseReducer };
useCallback & useMemo – Performance Optimization
import { useState, useCallback, useMemo, memo } from 'react'; // useCallback - memoizes functions function ParentComponent() { const [count, setCount] = useState(0); const [text, setText] = useState(''); // Without useCallback - function recreated on every render const handleClickBad = () => { console.log('Button clicked'); }; // With useCallback - function is memoized const handleClickGood = useCallback(() => { console.log('Button clicked'); }, []); // Empty deps = function never changes // With dependencies const handleIncrement = useCallback(() => { setCount(c => c + 1); }, []); // setCount is stable, so empty deps is fine const handleLogCount = useCallback(() => { console.log(`Current count: ${count}`); }, [count]); // Recreate when count changes return ( <div> <button onClick={handleIncrement}>Count: {count}</button> <ChildComponent onClick={handleClickGood} /> {/* Won't re-render unnecessarily */} <input value={text} onChange={(e) => setText(e.target.value)} /> </div> ); } const ChildComponent = memo(({ onClick }) => { console.log('ChildComponent rendered'); return <button onClick={onClick}>Click me</button>; }); // useMemo - memoizes computed values function ExpensiveComponent({ items, filterText }) { const [count, setCount] = useState(0); // Without useMemo - recalculates on every render const filteredItemsBad = items.filter(item => item.name.toLowerCase().includes(filterText.toLowerCase()) ); // With useMemo - only recalculates when dependencies change const filteredItemsGood = useMemo(() => { console.log('Filtering items...'); return items.filter(item => item.name.toLowerCase().includes(filterText.toLowerCase()) ); }, [items, filterText]); // Another expensive calculation const statistics = useMemo(() => { console.log('Calculating statistics...'); const total = items.reduce((sum, item) => sum + item.value, 0); const average = total / items.length; const max = Math.max(...items.map(item => item.value)); const min = Math.min(...items.map(item => item.value)); return { total, average, max, min }; }, [items]); return ( <div> <button onClick={() => setCount(count + 1)}>Re-render count: {count}</button> <div> <h3>Statistics</h3> <p>Total: {statistics.total}</p> <p>Average: {statistics.average}</p> <p>Max: {statistics.max}</p> <p>Min: {statistics.min}</p> </div> <ul> {filteredItemsGood.map(item => ( <li key={item.id}>{item.name}: {item.value}</li> ))} </ul> </div> ); } // Real-world example: Search with debouncing function SearchComponent() { const [query, setQuery] = useState(''); const [results, setResults] = useState([]); const [loading, setLoading] = useState(false); // Memoize search function const searchAPI = useCallback(async (searchQuery) => { if (!searchQuery) return []; setLoading(true); const response = await fetch(`/api/search?q=${searchQuery}`); const data = await response.json(); setLoading(false); return data; }, []); // Debounced search const debouncedSearch = useMemo(() => { let timeoutId; return (value) => { clearTimeout(timeoutId); timeoutId = setTimeout(async () => { const results = await searchAPI(value); setResults(results); }, 300); }; }, [searchAPI]); const handleChange = (e) => { const value = e.target.value; setQuery(value); debouncedSearch(value); }; return ( <div> <input value={query} onChange={handleChange} placeholder="Search..." /> {loading && <div>Loading...</div>} <ul> {results.map(result => ( <li key={result.id}>{result.title}</li> ))} </ul> </div> ); } // When to use useCallback vs useMemo function ComparisonExample() { const [data, setData] = useState([]); // useCallback: memoizes the FUNCTION itself const processData = useCallback((item) => { return item * 2; }, []); // useMemo: memoizes the RESULT of the function const processedData = useMemo(() => { return data.map(processData); }, [data, processData]); // Don't over-optimize! // ❌ Unnecessary useMemo const simpleValue = useMemo(() => 42, []); // Just use const simpleValue = 42; // ❌ Unnecessary useCallback const simpleFunction = useCallback(() => { console.log('hello'); }, []); // Just define the function normally // ✅ Use when: // 1. Function is passed to memoized child components // 2. Function is a dependency of useEffect // 3. Function is expensive to create (rare) return <div>{/* ... */}</div>; } export { ParentComponent, ExpensiveComponent, SearchComponent };
useRef – Accessing DOM and Persistent Values
import { useRef, useState, useEffect } from 'react'; // Accessing DOM elements function TextInputWithFocus() { const inputRef = useRef(null); const focusInput = () => { inputRef.current.focus(); }; return ( <div> <input ref={inputRef} type="text" /> <button onClick={focusInput}>Focus Input</button> </div> ); } // Measuring DOM elements function MeasureExample() { const [height, setHeight] = useState(0); const divRef = useRef(null); useEffect(() => { if (divRef.current) { setHeight(divRef.current.clientHeight); } }, []); // Run once after mount return ( <div> <div ref={divRef} style={{ padding: '20px', background: '#f0f0f0' }}> <h3>Measurable content</h3> <p>This div's height is being measured</p> </div> <p>Div height: {height}px</p> </div> ); } // Storing mutable values (doesn't trigger re-render) function TimerWithRef() { const [count, setCount] = useState(0); const intervalRef = useRef(null); const startTimer = () => { if (intervalRef.current) return; intervalRef.current = setInterval(() => { setCount(c => c + 1); }, 1000); }; const stopTimer = () => { if (intervalRef.current) { clearInterval(intervalRef.current); intervalRef.current = null; } }; useEffect(() => { return () => stopTimer(); // Cleanup on unmount }, []); return ( <div> <p>Count: {count}</p> <button onClick={startTimer}>Start</button> <button onClick={stopTimer}>Stop</button> </div> ); } // Keeping previous values function usePrevious(value) { const ref = useRef(); useEffect(() => { ref.current = value; }, [value]); return ref.current; } function CounterWithPrevious() { const [count, setCount] = useState(0); const prevCount = usePrevious(count); return ( <div> <p>Now: {count}, before: {prevCount}</p> <button onClick={() => setCount(count + 1)}>Increment</button> </div> ); } // Storing instance variables (like this in class components) function FormWithRefs() { const [formData, setFormData] = useState({ name: '', email: '' }); const submitCountRef = useRef(0); const startTimeRef = useRef(Date.now()); const handleSubmit = (e) => { e.preventDefault(); submitCountRef.current++; console.log(`Submit count: ${submitCountRef.current}`); console.log(`Time on page: ${Date.now() - startTimeRef.current}ms`); }; return ( <form onSubmit={handleSubmit}> <input value={formData.name} onChange={(e) => setFormData({ ...formData, name: e.target.value })} /> <input value={formData.email} onChange={(e) => setFormData({ ...formData, email: e.target.value })} /> <button type="submit">Submit</button> </form> ); } // Combining refs (forwarding refs) const FancyInput = React.forwardRef((props, ref) => { const inputRef = useRef(); // Expose multiple methods via ref useImperativeHandle(ref, () => ({ focus: () => inputRef.current.focus(), blur: () => inputRef.current.blur(), setValue: (value) => { inputRef.current.value = value; }, getValue: () => inputRef.current.value })); return <input ref={inputRef} {...props} />; }); function ParentWithFancyInput() { const fancyInputRef = useRef(); return ( <div> <FancyInput ref={fancyInputRef} placeholder="Type here..." /> <button onClick={() => fancyInputRef.current.focus()}>Focus</button> <button onClick={() => fancyInputRef.current.setValue('Hello!')}>Set Value</button> <button onClick={() => alert(fancyInputRef.current.getValue())}>Get Value</button> </div> ); } // useRef vs useState comparison function Comparison() { const [stateCount, setStateCount] = useState(0); const refCount = useRef(0); const incrementState = () => { setStateCount(stateCount + 1); // Triggers re-render }; const incrementRef = () => { refCount.current++; // Does NOT trigger re-render console.log(`Ref value: ${refCount.current}`); }; return ( <div> <p>State: {stateCount} (re-renders on change)</p> <p>Ref: {refCount.current} (no re-render)</p> <button onClick={incrementState}>Update State</button> <button onClick={incrementRef}>Update Ref</button> </div> ); } export { TextInputWithFocus, MeasureExample, TimerWithRef, CounterWithPrevious, FormWithRefs, ParentWithFancyInput, Comparison };
Custom Hooks – Reusing Logic
import { useState, useEffect, useCallback, useRef } from 'react'; // Hook for fetching data function useFetch(url, options = {}) { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { const abortController = new AbortController(); const fetchData = async () => { try { setLoading(true); const response = await fetch(url, { ...options, signal: abortController.signal }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const result = await response.json(); setData(result); setError(null); } catch (err) { if (err.name !== 'AbortError') { setError(err.message); } } finally { setLoading(false); } }; fetchData(); return () => abortController.abort(); }, [url, JSON.stringify(options)]); 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>; } // Hook for local storage function useLocalStorage(key, initialValue) { const [storedValue, setStoredValue] = useState(() => { try { const item = window.localStorage.getItem(key); return item ? JSON.parse(item) : initialValue; } catch (error) { console.log(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.log(error); } }; return [storedValue, setValue]; } // Usage function ThemeToggle() { const [theme, setTheme] = useLocalStorage('theme', 'light'); return ( <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}> Current theme: {theme} </button> ); } // Hook for window size function useWindowSize() { const [windowSize, setWindowSize] = useState({ width: window.innerWidth, height: window.innerHeight }); useEffect(() => { const handleResize = () => { setWindowSize({ width: window.innerWidth, height: window.innerHeight }); }; window.addEventListener('resize', handleResize); handleResize(); // Call immediately return () => window.removeEventListener('resize', handleResize); }, []); return windowSize; } // Hook for media query function useMediaQuery(query) { const [matches, setMatches] = useState(false); useEffect(() => { const media = window.matchMedia(query); if (media.matches !== matches) { setMatches(media.matches); } const listener = (e) => setMatches(e.matches); media.addEventListener('change', listener); return () => media.removeEventListener('change', listener); }, [query, matches]); return matches; } // Hook for click outside function useClickOutside(ref, handler) { useEffect(() => { const listener = (event) => { if (!ref.current || ref.current.contains(event.target)) { return; } handler(event); }; document.addEventListener('mousedown', listener); document.addEventListener('touchstart', listener); return () => { document.removeEventListener('mousedown', listener); document.removeEventListener('touchstart', listener); }; }, [ref, handler]); } // Hook for debouncing function useDebounce(value, delay) { const [debouncedValue, setDebouncedValue] = useState(value); useEffect(() => { const handler = setTimeout(() => { setDebouncedValue(value); }, delay); return () => clearTimeout(handler); }, [value, delay]); return debouncedValue; } // Hook for previous value function usePrevious(value) { const ref = useRef(); useEffect(() => { ref.current = value; }, [value]); return ref.current; } // Hook for toggle function useToggle(initialValue = false) { const [value, setValue] = useState(initialValue); const toggle = useCallback(() => setValue(v => !v), []); const setTrue = useCallback(() => setValue(true), []); const setFalse = useCallback(() => setValue(false), []); return { value, toggle, setTrue, setFalse }; } // Hook for counter function useCounter(initialValue = 0, step = 1) { const [count, setCount] = useState(initialValue); const increment = useCallback(() => setCount(c => c + step), [step]); const decrement = useCallback(() => setCount(c => c - step), [step]); const reset = useCallback(() => setCount(initialValue), [initialValue]); const set = useCallback((value) => setCount(value), []); return { count, increment, decrement, reset, set }; } // Hook for form handling function useForm(initialValues, validate) { const [values, setValues] = useState(initialValues); const [errors, setErrors] = useState({}); const [touched, setTouched] = useState({}); const handleChange = (e) => { const { name, value, type, checked } = e.target; const newValue = type === 'checkbox' ? checked : value; setValues(prev => ({ ...prev, [name]: newValue })); if (validate) { const error = validate(name, newValue); setErrors(prev => ({ ...prev, [name]: error })); } }; const handleBlur = (e) => { const { name } = e.target; setTouched(prev => ({ ...prev, [name]: true })); }; const handleSubmit = (callback) => (e) => { e.preventDefault(); // Validate all fields if (validate) { const newErrors = {}; Object.keys(values).forEach(key => { const error = validate(key, values[key]); if (error) newErrors[key] = error; }); setErrors(newErrors); setTouched(Object.keys(values).reduce((acc, key) => ({ ...acc, [key]: true }), {})); if (Object.keys(newErrors).length === 0) { callback(values); } } else { callback(values); } }; const reset = () => { setValues(initialValues); setErrors({}); setTouched({}); }; return { values, errors, touched, handleChange, handleBlur, handleSubmit, reset }; } // Complete example using multiple custom hooks function SearchWithHooks() { const [searchTerm, setSearchTerm] = useState(''); const debouncedSearchTerm = useDebounce(searchTerm, 500); const { data: results, loading } = useFetch( debouncedSearchTerm ? `/api/search?q=${debouncedSearchTerm}` : null ); const isMobile = useMediaQuery('(max-width: 768px)'); return ( <div> <input type="text" placeholder="Search..." value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} style={{ width: isMobile ? '100%' : '300px' }} /> {loading && <div>Searching...</div>} {results && ( <ul> {results.map(result => ( <li key={result.id}>{result.title}</li> ))} </ul> )} </div> ); } export { useFetch, useLocalStorage, useWindowSize, useMediaQuery, useClickOutside, useDebounce, usePrevious, useToggle, useCounter, useForm, SearchWithHooks };
Additional Built-in Hooks
import { useImperativeHandle, useLayoutEffect, useDebugValue, useId, useTransition, useDeferredValue, useSyncExternalStore } from 'react'; // useId - Generate unique IDs for accessibility function FormFields() { const id = useId(); const emailId = `${id}-email`; const passwordId = `${id}-password`; return ( <div> <label htmlFor={emailId}>Email:</label> <input id={emailId} type="email" /> <label htmlFor={passwordId}>Password:</label> <input id={passwordId} type="password" /> </div> ); } // useTransition - Mark updates as non-urgent function TabContainer() { const [tab, setTab] = useState('home'); const [isPending, startTransition] = useTransition(); const handleTabChange = (newTab) => { startTransition(() => { setTab(newTab); }); }; return ( <div> <button onClick={() => handleTabChange('home')}>Home</button> <button onClick={() => handleTabChange('profile')}>Profile</button> {isPending && <div>Loading...</div>} <SlowComponent tab={tab} /> </div> ); } // useDeferredValue - Defer updating a value function SearchResults({ query }) { const deferredQuery = useDeferredValue(query); const isStale = query !== deferredQuery; return ( <div style={{ opacity: isStale ? 0.5 : 1 }}> <ExpensiveSearchResults query={deferredQuery} /> </div> ); } // useLayoutEffect - Runs synchronously after DOM mutations function MeasureElement() { const [height, setHeight] = useState(0); const ref = useRef(); useLayoutEffect(() => { // Runs before browser paints setHeight(ref.current.clientHeight); }, []); return ( <div ref={ref}> <p>Height: {height}px</p> </div> ); } // useDebugValue - Label custom hooks in React DevTools function useFriendStatus(friendID) { const [isOnline, setIsOnline] = useState(null); useDebugValue(isOnline ? 'Online' : 'Offline'); useDebugValue(friendID, id => `Friend ${id}`); // ... hook logic return isOnline; } // useSyncExternalStore - Subscribe to external stores function useOnlineStatus() { const isOnline = useSyncExternalStore( (callback) => { window.addEventListener('online', callback); window.addEventListener('offline', callback); return () => { window.removeEventListener('online', callback); window.removeEventListener('offline', callback); }; }, () => navigator.onLine, () => true // Server snapshot ); return isOnline; } export { FormFields, TabContainer, SearchResults, MeasureElement, useFriendStatus, useOnlineStatus };
Hooks Best Practices
- Follow the Rules of Hooks – Only call hooks at the top level, only call hooks from React functions
- Use ESLint Plugin – eslint-plugin-react-hooks helps enforce rules and best practices
- Keep hooks focused – Each hook should have a single responsibility
- Extract custom hooks – Reuse logic by creating custom hooks instead of duplicating code
- Name custom hooks with 'use' prefix – This helps identify hooks and enables linting rules
- Minimize useEffect dependencies – Only include variables that the effect actually uses
- Clean up effects – Always return cleanup functions from useEffect to prevent memory leaks
- Avoid infinite loops – Be careful with state updates in useEffect that cause re-runs
- Use functional updates – When new state depends on previous state, use the functional form
- Memoize expensive operations – Use useMemo and useCallback appropriately
- Don't over-optimize – Not everything needs memoization; measure performance first
- Keep components pure – Avoid side effects in render phase
- Use useReducer for complex state – When state logic is complex, useReducer is more maintainable
- Lift state when needed – Share state by lifting it to common ancestors
- Use Context for global state – For truly global state (theme, user), useContext is great
- Test hooks – Use React Hooks Testing Library to test custom hooks
- Document custom hooks – Add JSDoc comments to explain parameters and return values
- Version hooks – When sharing hooks, use semantic versioning
- Avoid large dependency arrays – Break effects into smaller, focused effects
- Use useRef for mutable values – When you need to persist values without causing re-renders
Common Hook Mistakes
- ❌ Calling hooks conditionally – Hooks must be called in the same order every render
- ❌ Missing dependencies in useEffect – Always include all variables used in the effect
- ❌ Creating infinite loops – Updating state that's a dependency of the same effect
- ❌ Not cleaning up effects – Forgetting to clear intervals, subscriptions, or event listeners
- ❌ Overusing useState – Using multiple useState calls when an object would be cleaner
- ❌ Directly mutating state – Always use the state setter function
- ❌ Stale closures – Using state values that are outdated in callbacks or effects
- ❌ Not using functional updates – When new state depends on previous state
- ❌ Premature optimization – Using useMemo/useCallback everywhere unnecessarily
- ❌ Using useRef for state that should trigger re-renders – useRef changes don't cause re-renders
- ❌ Setting state in useEffect without conditions – Can cause unnecessary re-renders
- ❌ Not handling loading/error states – Always consider async operations
- ❌ Prop drilling with hooks – Using hooks to pass data through many levels instead of Context
- ❌ Creating new objects in dependencies – Object literals break dependency comparison
- ❌ Calling hooks in event handlers – Hooks can only be called during render
- ❌ Ignoring abort controllers – Forgetting to cancel fetch requests on unmount
- ❌ Using useEffect for derived state – Computed values don't need useEffect
- ❌ Not extracting custom hooks – Duplicating hook logic across components
Hooks Comparison Table
| Hook | Purpose | Use Case | Class Equivalent |
|---|---|---|---|
| useState | Local state management | Simple values, counters, toggles | this.state & this.setState |
| useEffect | Side effects | Data fetching, subscriptions, DOM updates | componentDidMount, componentDidUpdate, componentWillUnmount |
| useContext | Global state | Theme, user, language preferences | Context.Consumer |
| useReducer | Complex state logic | Forms, todo lists, state machines | this.state with complex updates |
| useCallback | Memoize functions | Preventing unnecessary re-renders | Class methods with binding |
| useMemo | Memoize values | Expensive calculations | Manual memoization |
| useRef | Mutable references | DOM access, instance variables | createRef, instance properties |
| useImperativeHandle | Custom ref methods | Exposing specific methods | Forwarding refs |
| useLayoutEffect | Synchronous effects | DOM measurements, mutations | componentDidMount/Update (sync) |
| useDebugValue | DevTools labeling | Custom hook debugging | N/A |
| useId | Unique IDs | Accessibility, form labels | Manual ID generation |
| useTransition | Non-urgent updates | Deferred UI updates | N/A |
| useDeferredValue | Defer value updates | Optimistic UI, search | N/A |
| useSyncExternalStore | External store subscription | Third-party stores | N/A |
Migration from Class to Hooks
// Class component class ClassCounter extends React.Component { state = { count: 0 }; componentDidMount() { console.log('Mounted'); } componentDidUpdate(prevProps, prevState) { if (prevState.count !== this.state.count) { console.log('Count changed'); } } componentWillUnmount() { console.log('Unmounted'); } increment = () => { this.setState({ count: this.state.count + 1 }); }; render() { return <button onClick={this.increment}>{this.state.count}</button>; } } // Same functionality with hooks function FunctionalCounter() { const [count, setCount] = useState(0); useEffect(() => { console.log('Mounted'); return () => console.log('Unmounted'); }, []); useEffect(() => { console.log('Count changed'); }, [count]); return <button onClick={() => setCount(c => c + 1)}>{count}</button>; } // Class with multiple lifecycle methods class ClassDataFetcher extends React.Component { state = { data: null, loading: true, error: null }; componentDidMount() { this.fetchData(); } componentDidUpdate(prevProps) { if (prevProps.userId !== this.props.userId) { this.fetchData(); } } fetchData = async () => { try { this.setState({ loading: true }); const response = await fetch(`/api/users/${this.props.userId}`); const data = await response.json(); this.setState({ data, loading: false }); } catch (error) { this.setState({ error, loading: false }); } }; render() { // ... render logic } } // Same with hooks function FunctionalDataFetcher({ userId }) { const [state, setState] = useState({ data: null, loading: true, error: null }); useEffect(() => { let isMounted = true; const fetchData = async () => { try { setState(prev => ({ ...prev, loading: true })); const response = await fetch(`/api/users/${userId}`); const data = await response.json(); if (isMounted) { setState({ data, loading: false, error: null }); } } catch (error) { if (isMounted) { setState({ data: null, loading: false, error }); } } }; fetchData(); return () => { isMounted = false; }; }, [userId]); // ... render logic } export { ClassCounter, FunctionalCounter, ClassDataFetcher, FunctionalDataFetcher };
Conclusion
React Hooks have revolutionized how we write React applications by enabling functional components to have state and lifecycle features. They provide a more direct API to React concepts you already know: props, state, context, refs, and lifecycle. Hooks make code more reusable, composable, and easier to understand. Start with useState and useEffect, then gradually incorporate other hooks as needed. Remember to follow the Rules of Hooks and extract custom hooks when you find yourself duplicating logic. With hooks, you can build modern, performant React applications with cleaner and more maintainable code.