The Philosophy of Hooks
Hooks allow you to reuse stateful logic without changing your component hierarchy. But they come with rules. Released in React 16.8, hooks revolutionized how we write React components by allowing functional components to have state and lifecycle features previously only available in class components.
The Philosophy of Hooks
Hooks allow you to reuse stateful logic without changing your component hierarchy. But they come with rules. Released in React 16.8, hooks revolutionized how we write React components by allowing functional components to have state and lifecycle features previously only available in class components.
useState - Managing Component State
The most basic hook, but understanding its nuances is crucial for writing correct React code:
// Basic usage
const [count, setCount] = useState(0)
// With more complex state
interface User {
name: string
email: string
role: 'admin' | 'user'
}
const [user, setUser] = useState<User | null>(null)
// Lazy initialization - for expensive computations
const [state, setState] = useState(() => {
// This function only runs once on mount, not on every render
return expensiveInitialization()
})
// Functional updates to avoid stale state
setCount(prev => prev + 1) // Always has current value
The key insight: setState is asynchronous. If you need state to update synchronously, you're approaching the problem wrong.
The Dependency Array - The Most Common Source of Bugs
The most common source of bugs in React is lying to React about dependencies. If you use a variable inside useEffect, it MUST be in the dependency array. This is not optional—it's a critical rule.
// WRONG - will cause stale closures
useEffect(() => {
const timer = setInterval(() => {
setCount(count + 1) // count is always the same value!
}, 1000)
}, []) // Missing 'count' in dependencies
// CORRECT - updates properly
useEffect(() => {
const timer = setInterval(() => {
setCount(prev => prev + 1)
}, 1000)
return () => clearInterval(timer) // Always cleanup!
}, []) // No dependencies needed with functional updates
Understanding Closures in useEffect
Closures are a common source of confusion. Each time your component renders, it creates a new function scope. If you reference a variable from outside that scope, you're creating a closure.
function UserProfile({ userId }) {
useEffect(() => {
// This closure captures userId at render time
fetchUser(userId)
}, [userId]) // Must include userId as dependency
}
// Every change to userId triggers a new effect
// userId is "stale" if not in dependency array
useEffect - Managing Side Effects
useEffect is where side effects happen in functional components. Understanding its timing and cleanup is critical.
Effect Timing
function Component() {
const [data, setData] = useState(null)
// Runs AFTER every render, including after mount
useEffect(() => {
console.log('Effect ran')
})
// Runs AFTER render, but only when dependencies change
useEffect(() => {
loadData()
}, [])
// Runs only once on mount
useEffect(() => {
const subscription = subscribeToUpdates()
// Cleanup runs before component unmounts
return () => {
subscription.unsubscribe()
}
}, [])
}
Cleanup Functions
Always cleanup subscriptions, timers, and event listeners to prevent memory leaks:
// BAD - memory leak, timer keeps running
useEffect(() => {
const timer = setInterval(() => {
console.log('tick')
}, 1000)
})
// GOOD - cleanup timer
useEffect(() => {
const timer = setInterval(() => {
console.log('tick')
}, 1000)
return () => clearInterval(timer)
}, [])
// Unsubscribing from events
useEffect(() => {
const handleResize = () => {
console.log(window.innerWidth)
}
window.addEventListener('resize', handleResize)
return () => {
window.removeEventListener('resize', handleResize)
}
}, [])
useMemo and useCallback - Performance Optimization
Both are performance optimizations, but they solve different problems. Understanding when to use each is crucial.
useMemo - Memoizing Computed Values
Use when you have expensive calculations that shouldn't be recomputed on every render:
// Without memoization - recalculates every render
function UserList({ users, filterTerm }) {
const filteredUsers = users.filter(u =>
u.name.includes(filterTerm)
) // Expensive if users array is large
return <UserListDisplay users={filteredUsers} />
}
// With memoization
function UserList({ users, filterTerm }) {
const filteredUsers = useMemo(
() => users.filter(u => u.name.includes(filterTerm)),
[users, filterTerm]
)
return <UserListDisplay users={filteredUsers} />
}
Important: Only use useMemo if you've actually measured a performance problem. It has overhead too.
useCallback - Memoizing Function Definitions
Use when passing functions to memoized child components, or when the function is in a dependency array:
// Child component is optimized with React.memo
const MemoizedButton = React.memo(({ onClick, label }) => {
console.log('Button rendered')
return <button onClick={onClick}>{label}</button>
})
// Without useCallback - new function on every render
function Parent() {
const handleClick = () => {
console.log('clicked')
}
return <MemoizedButton onClick={handleClick} label="Click me" />
// MemoizedButton re-renders every time because onClick changes
}
// With useCallback - same function reference
function Parent() {
const handleClick = useCallback(() => {
console.log('clicked')
}, [])
return <MemoizedButton onClick={handleClick} label="Click me" />
// MemoizedButton only re-renders if label changes
}
When NOT to Use These Hooks
// DON'T memoize primitive values
const x = useMemo(() => 5, []) // Pointless
// DON'T memoize without dependencies
const value = useMemo(() => calculateSomething(), []) // Can still change
// DON'T useCallback for every function
const handleClick = useCallback(() => {}, []) // Only if it's a dependency
// GOOD - only when there's a performance benefit
const expensiveValue = useMemo(() => {
return items.filter(...).map(...).reduce(...)
}, [items])
Building Custom Hooks
Custom hooks let you extract component logic into reusable functions. A custom hook is a JavaScript function whose name starts with "use" and may call other hooks.
Simple Custom Hook - useWindowSize
// Custom hook - useWindowSize
function useWindowSize() {
const [size, setSize] = useState({ width: 0, height: 0 })
useEffect(() => {
const handleResize = () => {
setSize({ width: window.innerWidth, height: window.innerHeight })
}
window.addEventListener('resize', handleResize)
handleResize() // Call once to set initial size
return () => window.removeEventListener('resize', handleResize)
}, [])
return size
}
// Usage
function MyComponent() {
const { width, height } = useWindowSize()
return <div>Window is {width}x{height}</div>
}
Advanced Custom Hook - useFetch
A real-world custom hook that handles loading, error, and data states:
interface UseFetchResult<T> {
data: T | null
loading: boolean
error: Error | null
retry: () => void
}
function useFetch<T>(
url: string,
options?: RequestInit
): UseFetchResult<T> {
const [data, setData] = useState<T | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
const fetchData = useCallback(async () => {
setLoading(true)
setError(null)
try {
const response = await fetch(url, options)
if (!response.ok) throw new Error('Failed to fetch')
const json = await response.json()
setData(json)
} catch (err) {
setError(err instanceof Error ? err : new Error('Unknown error'))
} finally {
setLoading(false)
}
}, [url, options])
useEffect(() => {
fetchData()
}, [fetchData])
return { data, loading, error, retry: fetchData }
}
// Usage
function UserList() {
const { data: users, loading, error } = useFetch<User[]>('/api/users')
if (loading) return <div>Loading...</div>
if (error) return <div>Error: {error.message}</div>
return <ul>{users?.map(u => <li key={u.id}>{u.name}</li>)}</ul>
}
Common Pitfalls and How to Avoid Them
1. Infinite Loops with useEffect
If you update state in useEffect without proper dependencies, you'll create an infinite loop:
// INFINITE LOOP - don't do this!
useEffect(() => {
setData(data + 1) // Causes re-render, which calls effect again
}) // No dependency array = runs after EVERY render
// CORRECT - run only once on mount
useEffect(() => {
fetchData()
}, []) // Empty dependency array
// CORRECT - run when specific deps change
useEffect(() => {
loadUserData(userId)
}, [userId]) // Only runs when userId changes
2. Stale Closures
Functions defined in useEffect capture values from the current render. If you need current values, use functional setState:
// WRONG - stale count
const [count, setCount] = useState(0)
useEffect(() => {
const timer = setInterval(() => {
setCount(count + 1) // count is always 0
}, 1000)
}, [])
// CORRECT - use functional updates
useEffect(() => {
const timer = setInterval(() => {
setCount(prev => prev + 1) // prev is always current
}, 1000)
return () => clearInterval(timer)
}, [])
3. Missing Cleanup
Forgetting to cleanup can cause memory leaks and bugs:
// WRONG - listener stays attached
useEffect(() => {
window.addEventListener('scroll', handleScroll)
// No cleanup!
})
// CORRECT - remove listener on cleanup
useEffect(() => {
window.addEventListener('scroll', handleScroll)
return () => window.removeEventListener('scroll', handleScroll)
}, [])
useReducer for Complex State Logic
When state logic becomes complex with multiple sub-values, useReducer is more appropriate than multiple useState calls:
interface State {
count: number
error: string | null
loading: boolean
}
type Action =
| { type: 'INCREMENT' }
| { type: 'DECREMENT' }
| { type: 'SET_ERROR'; payload: string }
| { type: 'SET_LOADING'; payload: boolean }
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'INCREMENT':
return { ...state, count: state.count + 1 }
case 'DECREMENT':
return { ...state, count: state.count - 1 }
case 'SET_ERROR':
return { ...state, error: action.payload }
case 'SET_LOADING':
return { ...state, loading: action.payload }
default:
return state
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, {
count: 0,
error: null,
loading: false,
})
return (
<div>
Count: {state.count}
<button onClick={() => dispatch({ type: 'INCREMENT' })}>+</button>
<button onClick={() => dispatch({ type: 'DECREMENT' })}>-</button>
</div>
)
}
Context + Hooks - Global State Management
Combine useContext with useReducer for lightweight global state management without Redux:
interface ThemeContextType {
theme: 'light' | 'dark'
toggleTheme: () => void
}
const ThemeContext = React.createContext<ThemeContextType | undefined>(undefined)
function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<'light' | 'dark'>('light')
const toggleTheme = useCallback(() => {
setTheme(prev => prev === 'light' ? 'dark' : 'light')
}, [])
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
)
}
function useTheme() {
const context = useContext(ThemeContext)
if (!context) {
throw new Error('useTheme must be used within ThemeProvider')
}
return context
}
// Usage
function App() {
const { theme, toggleTheme } = useTheme()
return (
<div style={{ background: theme === 'light' ? 'white' : 'black' }}>
<button onClick={toggleTheme}>Toggle Theme</button>
</div>
)
}
Performance Optimization Best Practices
- Profile First: Use React DevTools Profiler to identify actual performance problems before optimizing.
- Don't Over-Memoize: Memoization has a cost. Only use it when you've identified a real performance issue with measurements.
- Optimize Dependencies: Ensure dependency arrays only include values that actually matter for correctness.
- Use Lazy Initialization: Pass a function to useState for expensive initial state calculations.
- Avoid Creating Objects/Functions in Render: Create stable references using useMemo/useCallback.
- Split State Logically: Keep related state together, separate unrelated state to avoid unnecessary re-renders.
Advanced Patterns
useEffect with Async Operations
// DON'T do this - useEffect can't be async
useEffect(async () => {
const data = await fetchData()
}, [])
// DO this - create async function inside
useEffect(() => {
let isMounted = true
async function loadData() {
const data = await fetchData()
if (isMounted) {
setData(data)
}
}
loadData()
return () => {
isMounted = false
}
}, [])
Debouncing with Custom Hook
function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value)
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value)
}, delay)
return () => clearTimeout(handler)
}, [value, delay])
return debouncedValue
}
// Usage - debounce search input
function SearchUsers() {
const [searchTerm, setSearchTerm] = useState('')
const debouncedTerm = useDebounce(searchTerm, 500)
useEffect(() => {
if (debouncedTerm) {
searchAPI(debouncedTerm)
}
}, [debouncedTerm])
}
Hooks Rules
These rules are enforced by the ESLint plugin eslint-plugin-react-hooks:
- Only call hooks at the top level: Don't call hooks inside loops, conditions, or nested functions.
- Only call hooks from React functions: Call hooks from React components or custom hooks only.
- Use the ESLint plugin: It will catch violations of these rules automatically.
// WRONG - calling hook conditionally
function MyComponent({ shouldFetch }) {
if (shouldFetch) {
const data = useFetch('/api/data') // Hook in condition!
}
}
// CORRECT - always call, conditional logic inside
function MyComponent({ shouldFetch }) {
const data = useFetch(shouldFetch ? '/api/data' : null)
}
Testing Hooks
Use the @testing-library/react library's renderHook to test custom hooks:
import { renderHook, act } from '@testing-library/react'
import { useWindowSize } from './useWindowSize'
describe('useWindowSize', () => {
it('returns window dimensions', () => {
const { result } = renderHook(() => useWindowSize())
expect(result.current.width).toBeGreaterThan(0)
expect(result.current.height).toBeGreaterThan(0)
})
it('updates on window resize', () => {
const { result } = renderHook(() => useWindowSize())
act(() => {
window.dispatchEvent(new Event('resize'))
})
// Assert the update
})
})
Conclusion
Mastering React hooks is essential for modern React development. By understanding useState, useEffect, and custom hooks, along with proper dependency management and cleanup, you'll write more reliable, performant, and maintainable React applications. Start with the fundamentals, practice with real-world examples, and gradually adopt advanced patterns. Remember: the dependency array is your friend. Use it correctly, and 90% of React bugs disappear.