Introduction
useState and useEffect are the two most fundamental React hooks. Every React developer uses them daily, but subtle mistakes lead to bugs, stale closures, and infinite loops. These hooks replaced class component lifecycle methods and state management, providing a simpler and more composable way to manage state and side effects in React components.
Understanding the internals of these hooks is critical for writing correct React code. useState manages local component state with a declarative API, while useEffect handles side effects like data fetching, subscriptions, and DOM manipulation. Together, they form the foundation of every React application built since hooks were introduced in React 16.8.
This guide covers these hooks in depth with advanced patterns, common pitfalls, performance optimization techniques, and real-world patterns you'll encounter in production applications.
Understanding useState in Depth
How useState Works Internally
When you call useState, React creates a state cell in a fiber node's memoized state array. Each hook call in a component corresponds to a specific index in this array. This is why hooks must be called in the same order every render — React uses call order to match state cells to hook invocations.
// React internally tracks hooks by call order
function Counter() {
// Index 0: count state
const [count, setCount] = useState(0);
// Index 1: name state
const [name, setName] = useState('');
// Index 2: theme state
const [theme, setTheme] = useState('light');
// React maintains: [0, '', 'light'] as the state array
// Each setState call updates the corresponding index
}Lazy Initialization
For expensive computations, pass a function to useState. The initializer function runs only on the first render, avoiding unnecessary work on subsequent re-renders:
// Bad: runs on every render
const [state, setState] = useState(expensiveComputation());
// Good: runs only on first render
const [state, setState] = useState(() => expensiveComputation());
// Real-world example: parsing stored data
const [settings, setSettings] = useState(() => {
try {
const stored = localStorage.getItem('app-settings');
return stored ? JSON.parse(stored) : DEFAULT_SETTINGS;
} catch {
return DEFAULT_SETTINGS;
}
});Functional Updates
When new state depends on previous state, use the functional form. This is essential for correctness because state updates are batched and the closure value may be stale:
// Bad: may use stale state in closures
setCount(count + 1);
setCount(count + 1); // Still only increments by 1!
// Good: always uses the latest state
setCount(prev => prev + 1);
setCount(prev => prev + 1); // Correctly increments by 2
// Event handler demonstrating the difference
function Counter() {
const [count, setCount] = useState(0);
function handleClick() {
// If called 3 times rapidly, count is stale after first call
setCount(count + 1); // count = 0, sets 1
setCount(count + 1); // count = 0, sets 1 (stale!)
setCount(count + 1); // count = 0, sets 1 (stale!)
// Result: count = 1 (not 3!)
}
function handleClickCorrect() {
setCount(prev => prev + 1); // 0 -> 1
setCount(prev => prev + 1); // 1 -> 2
setCount(prev => prev + 1); // 2 -> 3
// Result: count = 3 ✓
}
}Object and Array State
React uses Object.is comparison to determine if state changed. For objects and arrays, you must create new references:
// Always create new references
const [user, setUser] = useState({ name: '', email: '' });
// Bad: mutates existing object — React doesn't detect change
user.name = 'John';
setUser(user); // Same reference, no re-render!
// Good: creates new object
setUser(prev => ({ ...prev, name: 'John' }));
// Array updates
const [items, setItems] = useState<string[]>([]);
// Add
setItems(prev => [...prev, 'new']);
// Remove by value
setItems(prev => prev.filter(item => item !== 'target'));
// Remove by index
setItems(prev => prev.filter((_, i) => i !== indexToRemove));
// Update at index
setItems(prev => prev.map((item, i) => i === targetIndex ? 'new' : item));
// Sort (must create new array)
setItems(prev => [...prev].sort());Complex State Patterns
For complex state logic, consider using useReducer as an alternative to multiple useState calls:
type State = {
items: Item[];
loading: boolean;
error: string | null;
page: number;
};
type Action =
| { type: 'FETCH_START' }
| { type: 'FETCH_SUCCESS'; items: Item[] }
| { type: 'FETCH_ERROR'; error: string }
| { type: 'NEXT_PAGE' }
| { type: 'PREV_PAGE' };
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'FETCH_START':
return { ...state, loading: true, error: null };
case 'FETCH_SUCCESS':
return { ...state, loading: false, items: action.items };
case 'FETCH_ERROR':
return { ...state, loading: false, error: action.error };
case 'NEXT_PAGE':
return { ...state, page: state.page + 1 };
case 'PREV_PAGE':
return { ...state, page: Math.max(0, state.page - 1) };
}
}
function DataList({ endpoint }: { endpoint: string }) {
const [state, dispatch] = useReducer(reducer, {
items: [],
loading: false,
error: null,
page: 0,
});
useEffect(() => {
dispatch({ type: 'FETCH_START' });
fetch(`${endpoint}?page=${state.page}`)
.then(res => res.json())
.then(items => dispatch({ type: 'FETCH_SUCCESS', items }))
.catch(err => dispatch({ type: 'FETCH_ERROR', error: err.message }));
}, [endpoint, state.page]);
if (state.loading) return <Spinner />;
if (state.error) return <ErrorMessage error={state.error} />;
return (
<div>
<ul>{state.items.map(item => <li key={item.id}>{item.name}</li>)}</ul>
<button onClick={() => dispatch({ type: 'PREV_PAGE' })}>Previous</button>
<span>Page {state.page + 1}</span>
<button onClick={() => dispatch({ type: 'NEXT_PAGE' })}>Next</button>
</div>
);
}Batching Behavior
React 18+ batches ALL state updates automatically, including those inside promises, setTimeout, and native event handlers:
function handleClick() {
// These are batched into a single re-render (React 18+)
setCount(c => c + 1);
setFlag(f => !f);
setText('updated');
// Component re-renders once
}
// React 18+ also batches in async contexts
async function handleSubmit() {
const data = await fetch('/api/data');
// Even these are batched in React 18+
setLoading(false);
setData(data);
setError(null);
}Understanding useEffect in Depth
How useEffect Works Internally
After every render, React runs effects whose dependencies have changed. The dependency array comparison uses Object.is (same as useState). React processes effects after the browser has painted, ensuring they don't block visual updates.
// The dependency array controls when the effect runs
useEffect(() => {
// Effect body
return () => {
// Cleanup runs before the next effect and on unmount
};
}, [dep1, dep2]);Dependency Arrays
The dependency array controls when the effect runs. Understanding the three forms is essential:
// Runs on every render (no array) — almost always wrong
useEffect(() => { console.log('Every render'); });
// Runs only on mount (empty array)
useEffect(() => { console.log('Mount only'); }, []);
// Runs when dependencies change
useEffect(() => { console.log('Count changed'); }, [count]);
// Multiple dependencies
useEffect(() => {
fetchUserData(userId, token);
}, [userId, token]); // Runs when either changesCleanup Functions
Cleanup functions prevent memory leaks by releasing resources when a component unmounts or before the effect re-runs:
// AbortController for fetch requests
useEffect(() => {
const controller = new AbortController();
fetch(url, { signal: controller.signal })
.then(res => res.json())
.then(setData)
.catch(err => {
if (err.name !== 'AbortError') setError(err);
});
return () => controller.abort();
}, [url]);
// Event listeners
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [onClose]);
// Intervals and timers
useEffect(() => {
const interval = setInterval(() => {
setSeconds(prev => prev + 1);
}, 1000);
return () => clearInterval(interval);
}, []);
// ResizeObserver
useEffect(() => {
const observer = new ResizeObserver(entries => {
for (const entry of entries) {
setDimensions({
width: entry.contentRect.width,
height: entry.contentRect.height,
});
}
});
const element = containerRef.current;
if (element) observer.observe(element);
return () => observer.disconnect();
}, []);Common Patterns
Data fetching with loading and error states:
function useFetch<T>(url: string) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
fetch(url)
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(data => {
if (!cancelled) { setData(data); setLoading(false); }
})
.catch(err => {
if (!cancelled) { setError(err); setLoading(false); }
});
return () => { cancelled = true; };
}, [url]);
return { data, loading, error };
}
// Usage
function UserProfile({ userId }: { userId: string }) {
const { data: user, loading, error } = useFetch<User>(`/api/users/${userId}`);
if (loading) return <Skeleton />;
if (error) return <ErrorMessage error={error} />;
return <UserCard user={user!} />;
}Document title synchronization:
useEffect(() => {
const prevTitle = document.title;
document.title = `${count} items in cart`;
return () => { document.title = prevTitle; };
}, [count]);Click outside detection:
function useClickOutside(ref: RefObject<HTMLElement>, handler: () => void) {
useEffect(() => {
function handleClick(event: MouseEvent) {
if (ref.current && !ref.current.contains(event.target as Node)) {
handler();
}
}
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [ref, handler]);
}
// Usage
function Dropdown({ onClose }: { onClose: () => void }) {
const ref = useRef<HTMLDivElement>(null);
useClickOutside(ref, onClose);
return (
<div ref={ref} className="dropdown">
<DropdownContent />
</div>
);
}Advanced Patterns
Debounced Search with useEffect
function useDebouncedValue<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
function SearchPage() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebouncedValue(query, 300);
const { data: results, loading } = useFetch<SearchResult[]>(
`/api/search?q=${encodeURIComponent(debouncedQuery)}`
);
return (
<div>
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search..."
/>
{loading && <Spinner />}
{results?.map(result => <SearchResult key={result.id} item={result} />)}
</div>
);
}WebSocket Connection with useEffect
function useWebSocket(url: string) {
const [messages, setMessages] = useState<Message[]>([]);
const [connected, setConnected] = useState(false);
const wsRef = useRef<WebSocket | null>(null);
useEffect(() => {
const ws = new WebSocket(url);
wsRef.current = ws;
ws.onopen = () => setConnected(true);
ws.onclose = () => setConnected(false);
ws.onmessage = (event) => {
const message = JSON.parse(event.data);
setMessages(prev => [...prev, message]);
};
ws.onerror = (error) => console.error('WebSocket error:', error);
return () => {
ws.close();
};
}, [url]);
const sendMessage = useCallback((message: object) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify(message));
}
}, []);
return { messages, connected, sendMessage };
}Local Storage Synchronization
function useLocalStorage<T>(key: string, initialValue: T) {
const [value, setValue] = useState<T>(() => {
try {
const item = localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch {
return initialValue;
}
});
useEffect(() => {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch (error) {
console.error(`Failed to save to localStorage: ${error}`);
}
}, [key, value]);
// Listen for changes from other tabs
useEffect(() => {
function handleStorageChange(event: StorageEvent) {
if (event.key === key && event.newValue) {
setValue(JSON.parse(event.newValue));
}
}
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, [key]);
return [value, setValue] as const;
}Common Pitfalls and Solutions
Stale Closure
The most common useEffect bug. The closure captures stale values from a previous render:
// Problem: count is stale in the interval callback
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1); // Always uses initial count!
}, 1000);
return () => clearInterval(id);
}, [count]); // Re-creates interval on every count change (wasteful)
// Solution 1: use functional update
useEffect(() => {
const id = setInterval(() => {
setCount(prev => prev + 1); // Always uses latest count
}, 1000);
return () => clearInterval(id);
}, []); // Never needs to re-create
// Solution 2: use ref for mutable values
const countRef = useRef(count);
countRef.current = count;
useEffect(() => {
const id = setInterval(() => {
// Access latest value through ref
console.log('Current count:', countRef.current);
}, 1000);
return () => clearInterval(id);
}, []);Infinite Loop
Setting state in useEffect that triggers the same effect creates an infinite loop:
// Problem: setting state in useEffect with object dependency
const [data, setData] = useState([]);
useEffect(() => {
const result = fetchResults();
setData(result); // Creates new array reference
}, [data]); // Triggers effect again because data reference changed!
// INFINITE LOOP
// Solution: use a stable dependency
const [query, setQuery] = useState('');
useEffect(() => {
setData(fetchResults(query));
}, [query]); // Only depends on the query string
// Problem: creating objects/arrays in render as dependencies
useEffect(() => {
fetchData({ page, limit }); // Object created every render
}, [{ page, limit }]); // Always different reference — infinite loop
// Solution: use primitive dependencies
useEffect(() => {
fetchData({ page, limit });
}, [page, limit]); // Primitive values, stable comparisonMissing Dependencies
Omitting dependencies causes stale values and subtle bugs:
// Problem: missing 'userId' dependency
useEffect(() => {
fetchUser(userId); // Uses stale userId if it changes!
}, []); // Only runs on mount
// Solution: include all used values
useEffect(() => {
fetchUser(userId);
}, [userId]); // Re-runs when userId changes
// ESLint rule: react-hooks/exhaustive-deps
// This rule catches missing dependencies — don't disable it!Effect vs. Event Handler
Not everything needs to be an effect. Effects synchronize with external systems; events respond to user actions:
// Bad: effect for user-initiated action
useEffect(() => {
if (submitted) {
sendAnalytics('form_submitted', formData);
}
}, [submitted]);
// Good: event handler for user-initiated action
function handleSubmit() {
sendAnalytics('form_submitted', formData);
// ... rest of submit logic
}
// Bad: effect for derived state
const [total, setTotal] = useState(0);
useEffect(() => {
setTotal(items.reduce((sum, item) => sum + item.price, 0));
}, [items]);
// Good: derived state with useMemo
const total = useMemo(() => items.reduce((sum, item) => sum + item.price, 0), [items]);| Pitfall | Impact | Solution |
|---|---|---|
| Stale closures | Outdated values in callbacks | Use functional updates or refs |
| Infinite re-render loops | App crashes, browser freezes | Ensure stable dependencies, avoid setting state that triggers same effect |
| Missing dependencies | Bugs, stale data | Include all used values, use ESLint rule |
| No cleanup | Memory leaks, duplicate subscriptions | Return cleanup function |
| Effect for derived state | Unnecessary re-renders | Use useMemo instead |
| Effect for user actions | Wrong execution timing | Use event handlers |
Performance Optimization
Splitting Effects by Concern
Separate unrelated logic into different effects to avoid unnecessary re-runs:
// Bad: one effect doing multiple things
useEffect(() => {
fetchUserData(userId);
subscribeToNotifications(userId);
trackPageView(page);
}, [userId, page]); // Notification subscription re-runs when page changes!
// Good: split by concern
useEffect(() => {
fetchUserData(userId);
}, [userId]);
useEffect(() => {
subscribeToNotifications(userId);
return () => unsubscribe(userId);
}, [userId]);
useEffect(() => {
trackPageView(page);
}, [page]);Avoiding Unnecessary Re-renders
// Memoize expensive computations
const sortedItems = useMemo(() => {
return [...items].sort((a, b) => a.name.localeCompare(b.name));
}, [items]);
// Memoize callback props to prevent child re-renders
const handleSelect = useCallback((id: string) => {
setSelectedId(id);
}, []);
// Use useRef for values that don't affect rendering
const renderCount = useRef(0);
renderCount.current += 1;Custom Hooks for Reusable Logic
// Custom hook for intersection observer (lazy loading)
function useInView(options?: IntersectionObserverInit) {
const [inView, setInView] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => setInView(entry.isIntersecting),
options
);
if (ref.current) observer.observe(ref.current);
return () => observer.disconnect();
}, [options?.rootMargin, options?.threshold]);
return { ref, inView };
}
// Usage
function LazyImage({ src, alt }: { src: string; alt: string }) {
const { ref, inView } = useInView({ threshold: 0.1 });
return (
<div ref={ref}>
{inView ? <img src={src} alt={alt} /> : <ImagePlaceholder />}
</div>
);
}Comparison: useState vs useReducer
| Aspect | useState | useReducer |
|---|---|---|
| Best for | Simple, independent state | Complex, interdependent state |
| Update pattern | Direct value or function | Dispatch action object |
| Testing | Test component behavior | Test reducer in isolation |
| Boilerplate | Minimal | Requires action types and reducer |
| State transitions | Implicit in setState calls | Explicit in reducer cases |
Custom Hook Patterns for Real-World Applications
Custom hooks encapsulate reusable stateful logic that multiple components need. The useDebounce hook delays updating a value until the user stops typing for a specified duration, preventing excessive API calls during search input. The useLocalStorage hook synchronizes state with localStorage, providing persistence across page reloads while keeping the API identical to useState. These hooks compose useState and useEffect to create higher-level abstractions that simplify component code.
The usePrevious hook captures the previous value of a state variable by combining useRef and useEffect. This is valuable for animations that need to transition from the old value to the new value, or for comparison logic that determines whether a value has changed. The useInterval hook wraps setInterval in a hook that automatically clears the interval when the component unmounts and updates the callback when dependencies change, avoiding the stale closure problem that plagues direct setInterval usage.
Testing custom hooks requires the renderHook utility from React Testing Library. This function renders the hook in a test component and returns the current hook values. You can simulate state updates by calling the returned setter functions wrapped in act(), which ensures all pending effects and re-renders complete before assertions run. Test custom hooks in isolation to verify their behavior independently of any consuming component.
Effect Cleanup and Resource Management
The cleanup function returned by useEffect is critical for preventing memory leaks and stale operations. Event listeners, subscriptions, timers, and WebSocket connections all require cleanup when the component unmounts or when dependencies change. Failing to clean up these resources causes memory leaks that accumulate over time, degrading application performance and potentially causing crashes on resource-constrained devices.
The cleanup function runs before each subsequent effect execution and once when the component unmounts. This means the cleanup for the previous effect runs synchronously before the new effect runs, ensuring that old resources are released before new ones are acquired. For WebSocket connections, close the old connection in cleanup and open a new one in the effect body when the URL dependency changes.
Strict Mode in development double-invokes effects to help identify missing cleanup functions. The mount, unmount, mount sequence verifies that your cleanup function properly releases all resources. If you see unexpected behavior in development that doesn't occur in production, Strict Mode is likely the cause. Ensure every effect that allocates a resource has a corresponding cleanup function that releases it.
Conclusion
useState and useEffect are simple on the surface but have important nuances. Understanding stale closures, dependency arrays, and cleanup functions prevents the most common bugs. Proper use of functional updates, lazy initialization, and custom hooks leads to clean, performant React code.
Key takeaways:
- Use functional updates when new state depends on old state — avoids stale closure bugs
- Lazy initialization for expensive initial computations — pass a function, not a value
- Always include dependencies in the useEffect array — enable the ESLint rule
- Return cleanup functions for subscriptions, event listeners, and timers
- Avoid effects for derived state — use
useMemoinstead - Split effects by concern for better readability and fewer unnecessary re-runs
- Use useRef for mutable values that don't trigger re-renders
- Prefer event handlers over effects for user-initiated actions