React State and Side Effects (useEffect)
1. useEffect Hook with State Dependencies (React.useEffect)
useEffect Hook Syntax and State Dependencies
| Pattern | Syntax | Behavior |
|---|---|---|
| Basic useEffect | useEffect(() => { /* effect */ }) | Runs after every render |
| Empty Dependencies | useEffect(() => { /* effect */ }, []) | Runs only on mount |
| State Dependencies | useEffect(() => { /* effect */ }, [state]) | Runs when state changes |
| Multiple Dependencies | useEffect(() => { /* effect */ }, [a, b, c]) | Runs when any dependency changes |
| With Cleanup | useEffect(() => { return () => cleanup(); }, [deps]) | Cleanup runs before next effect and unmount |
Common useEffect with State Patterns
| Pattern | Use Case | Example |
|---|---|---|
| Fetch Data on State Change | Load data when filter/query changes | useEffect(() => fetchData(query), [query]) |
| Sync State to localStorage | Persist state changes | useEffect(() => localStorage.set(key, state), [state]) |
| Update Document Title | Reflect state in browser title | useEffect(() => document.title = title, [title]) |
| Focus Input on State | Focus element when condition changes | useEffect(() => inputRef.current?.focus(), [isEdit]) |
| Trigger Animation | Start animation on state change | useEffect(() => animate(), [isVisible]) |
Example: Fetch Data on State Change
const UserList = () => {
const [users, setUsers] = useState([]);
const [searchTerm, setSearchTerm] = useState('');
const [loading, setLoading] = useState(false);
// ✅ Fetch data when searchTerm changes
useEffect(() => {
const fetchUsers = async () => {
setLoading(true);
try {
const response = await fetch(`/api/users?search=${searchTerm}`);
const data = await response.json();
setUsers(data);
} catch (error) {
console.error('Failed to fetch users:', error);
} finally {
setLoading(false);
}
};
if (searchTerm) {
fetchUsers();
} else {
setUsers([]); // Clear users when search is empty
}
}, [searchTerm]); // Re-run when searchTerm changes
return (
<div>
<input
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search users..."
/>
{loading ? <p>Loading...</p> : <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>}
</div>
);
};
Example: Sync State to localStorage
const usePersistentState = (key, initialValue) => {
// Initialize from localStorage
const [state, setState] = useState(() => {
const stored = localStorage.getItem(key);
return stored ? JSON.parse(stored) : initialValue;
});
// ✅ Sync to localStorage whenever state changes
useEffect(() => {
localStorage.setItem(key, JSON.stringify(state));
}, [key, state]); // Dependencies include key and state
return [state, setState];
};
// Usage
const TodoApp = () => {
const [todos, setTodos] = usePersistentState('todos', []);
const addTodo = (text) => {
setTodos(prev => [...prev, { id: Date.now(), text }]);
};
// State automatically persists to localStorage
return <div>{/* ... */}</div>;
};
Warning: Avoid infinite loops! Don't update a state variable inside useEffect that's also in
the dependency array without proper conditions. Always include all dependencies that are used inside the
effect to avoid stale closures.
Example: ❌ Infinite Loop Anti-pattern
// ❌ BAD - Infinite loop!
const BadComponent = () => {
const [count, setCount] = useState(0);
useEffect(() => {
setCount(count + 1); // Updates count on every render
}, [count]); // count is dependency, creates infinite loop!
return <div>{count}</div>;
};
// ✅ GOOD - Conditional update
const GoodComponent = () => {
const [count, setCount] = useState(0);
const [maxReached, setMaxReached] = useState(false);
useEffect(() => {
if (count < 10 && !maxReached) {
setCount(count + 1);
} else {
setMaxReached(true);
}
}, [count, maxReached]); // Safe with condition
return <div>{count}</div>;
};
2. State Synchronization with External Systems
External System Synchronization Patterns
| System Type | Synchronization Method | Use Case |
|---|---|---|
| Browser APIs | useEffect with state dependencies | Window resize, scroll, media queries |
| Web Storage | useEffect + storage events | localStorage, sessionStorage persistence |
| WebSocket | useEffect with connection management | Real-time data updates |
| Third-party Libraries | useSyncExternalStore (React 18+) | Redux, MobX, external stores |
| Timers/Intervals | useEffect with cleanup | Polling, countdowns, animations |
| DOM Mutations | useEffect with MutationObserver | Track DOM changes outside React |
useSyncExternalStore Hook (React 18+)
| Feature | Description | Example |
|---|---|---|
| Purpose | Subscribe to external store safely | Concurrent rendering compatible |
| Syntax | useSyncExternalStore(subscribe, getSnapshot) | Returns current snapshot value |
| Subscribe Function | (callback) => unsubscribe | Called when store subscribes/unsubscribes |
| GetSnapshot Function | () => value | Returns current store value |
| Tearing Prevention | Prevents inconsistent UI during concurrent render | Critical for external stores |
Example: Window Resize State Synchronization
const useWindowSize = () => {
const [windowSize, setWindowSize] = useState({
width: window.innerWidth,
height: window.innerHeight
});
useEffect(() => {
// ✅ Sync state with window resize events
const handleResize = () => {
setWindowSize({
width: window.innerWidth,
height: window.innerHeight
});
};
window.addEventListener('resize', handleResize);
// Cleanup subscription on unmount
return () => window.removeEventListener('resize', handleResize);
}, []); // Empty deps - setup once on mount
return windowSize;
};
// Usage
const ResponsiveComponent = () => {
const { width, height } = useWindowSize();
return (
<div>
<p>Window size: {width} x {height}</p>
{width < 768 ? <MobileView /> : <DesktopView />}
</div>
);
};
Example: WebSocket State Synchronization
const useWebSocket = (url) => {
const [messages, setMessages] = useState([]);
const [isConnected, setIsConnected] = useState(false);
useEffect(() => {
// ✅ Connect to WebSocket and sync messages to state
const ws = new WebSocket(url);
ws.onopen = () => {
setIsConnected(true);
console.log('WebSocket connected');
};
ws.onmessage = (event) => {
const message = JSON.parse(event.data);
setMessages(prev => [...prev, message]);
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
setIsConnected(false);
};
ws.onclose = () => {
setIsConnected(false);
console.log('WebSocket disconnected');
};
// ✅ Cleanup - close connection on unmount
return () => {
ws.close();
};
}, [url]); // Reconnect if URL changes
const sendMessage = (message) => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(message));
}
};
return { messages, isConnected, sendMessage };
};
Example: useSyncExternalStore with Custom Store
// Custom external store
class CounterStore {
constructor() {
this.count = 0;
this.listeners = new Set();
}
subscribe = (callback) => {
this.listeners.add(callback);
return () => this.listeners.delete(callback);
};
getSnapshot = () => {
return this.count;
};
increment = () => {
this.count++;
this.listeners.forEach(listener => listener());
};
}
const counterStore = new CounterStore();
// ✅ Use with useSyncExternalStore (React 18+)
const useCounter = () => {
const count = useSyncExternalStore(
counterStore.subscribe,
counterStore.getSnapshot
);
return { count, increment: counterStore.increment };
};
// Usage
const Counter = () => {
const { count, increment } = useCounter();
return (
<div>
<p>Count: {count}</p>
<button onClick={increment}>Increment</button>
</div>
);
};
Note: When syncing with external systems, always clean up subscriptions in the useEffect
cleanup function to prevent memory leaks. Use useSyncExternalStore for external stores in React 18+ to ensure
concurrent rendering compatibility.
3. Cleanup Functions and State Management
Cleanup Function Patterns
| Pattern | Purpose | Example |
|---|---|---|
| Event Listener Cleanup | Remove DOM event listeners | return () => el.removeEventListener() |
| Timer Cleanup | Clear timeouts and intervals | return () => clearTimeout(timerId) |
| Subscription Cleanup | Unsubscribe from observables | return () => subscription.unsubscribe() |
| Request Cancellation | Cancel pending async requests | return () => abortController.abort() |
| Connection Cleanup | Close WebSocket/network connections | return () => ws.close() |
| Resource Release | Release memory, object URLs | return () => URL.revokeObjectURL(url) |
Cleanup Function Execution Timing
| Scenario | When Cleanup Runs | Purpose |
|---|---|---|
| Component Unmount | Before component is removed from DOM | Final cleanup before removal |
| Effect Re-run | Before effect runs again | Clean up previous effect's resources |
| Dependencies Change | Before effect with new dependencies | Reset for new dependency values |
| Strict Mode (Dev) | After mount, then remount immediately | Test cleanup function correctness |
Example: Timer Cleanup
const Countdown = ({ initialSeconds }) => {
const [seconds, setSeconds] = useState(initialSeconds);
const [isActive, setIsActive] = useState(false);
useEffect(() => {
if (!isActive) return; // Don't start timer if not active
// ✅ Start interval
const intervalId = setInterval(() => {
setSeconds(prev => {
if (prev <= 1) {
setIsActive(false);
return 0;
}
return prev - 1;
});
}, 1000);
// ✅ Cleanup - clear interval on unmount or when isActive changes
return () => {
clearInterval(intervalId);
console.log('Timer cleaned up');
};
}, [isActive]); // Re-run when isActive changes
return (
<div>
<p>Time remaining: {seconds}s</p>
<button onClick={() => setIsActive(!isActive)}>
{isActive ? 'Pause' : 'Start'}
</button>
</div>
);
};
Example: Abort Controller for Request Cancellation
const UserProfile = ({ userId }) => {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
// ✅ Create AbortController for this request
const abortController = new AbortController();
const fetchUser = async () => {
setLoading(true);
setError(null);
try {
const response = await fetch(`/api/users/${userId}`, {
signal: abortController.signal // Pass abort signal
});
if (!response.ok) throw new Error('Failed to fetch');
const data = await response.json();
setUser(data);
} catch (err) {
// ✅ Ignore abort errors (expected on cleanup)
if (err.name === 'AbortError') {
console.log('Request aborted');
return;
}
setError(err.message);
} finally {
setLoading(false);
}
};
fetchUser();
// ✅ Cleanup - abort pending request
return () => {
abortController.abort();
console.log('Fetch aborted for userId:', userId);
};
}, [userId]); // Re-run when userId changes, canceling previous request
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error}</p>;
return <div>{user?.name}</div>;
};
Example: Multiple Cleanup Operations
const ChatRoom = ({ roomId }) => {
const [messages, setMessages] = useState([]);
const [isConnected, setIsConnected] = useState(false);
useEffect(() => {
let ws;
let reconnectTimer;
const connect = () => {
ws = new WebSocket(`wss://chat.example.com/${roomId}`);
ws.onopen = () => setIsConnected(true);
ws.onmessage = (event) => {
const message = JSON.parse(event.data);
setMessages(prev => [...prev, message]);
};
ws.onclose = () => {
setIsConnected(false);
// Attempt reconnect after 5 seconds
reconnectTimer = setTimeout(connect, 5000);
};
};
connect();
// ✅ Cleanup multiple resources
return () => {
console.log('Cleaning up chat room:', roomId);
// Clear reconnect timer if pending
if (reconnectTimer) {
clearTimeout(reconnectTimer);
}
// Close WebSocket connection
if (ws) {
ws.onclose = null; // Prevent reconnect attempt
ws.close();
}
// Clear messages on room change
setMessages([]);
};
}, [roomId]); // Cleanup and reconnect when roomId changes
return (
<div>
<p>Status: {isConnected ? 'Connected' : 'Disconnected'}</p>
<ul>{messages.map((m, i) => <li key={i}>{m.text}</li>)}</ul>
</div>
);
};
Warning: Always return a cleanup function from useEffect when dealing with subscriptions,
timers, or async operations. Forgetting cleanup can lead to memory leaks, state updates on unmounted
components, and race conditions.
4. State and Subscription Patterns
Subscription Pattern Types
| Pattern | Implementation | Use Case |
|---|---|---|
| Observable Subscription | useEffect + observable.subscribe() | RxJS, streams, event emitters |
| Event Emitter Pattern | useEffect + emitter.on/off | Node.js EventEmitter, custom events |
| PubSub Pattern | useEffect + subscribe/unsubscribe | Message buses, global events |
| Store Subscription | useSyncExternalStore | Redux, Zustand, external stores |
| Real-time Data | useEffect + WebSocket/SSE | Live updates, notifications |
Subscription Best Practices
| Practice | Recommendation | Benefit |
|---|---|---|
| Always Unsubscribe | Return cleanup function | Prevent memory leaks |
| Stable Callbacks | Use useCallback for handlers | Avoid unnecessary resubscriptions |
| Error Handling | Handle subscription errors gracefully | Robust error recovery |
| Loading States | Track subscription connection status | Better UX with loading indicators |
| Reconnection Logic | Implement exponential backoff | Resilient connections |
Example: RxJS Observable Subscription
import { interval } from 'rxjs';
import { map, filter } from 'rxjs/operators';
const useObservable = (observable$, initialValue) => {
const [value, setValue] = useState(initialValue);
const [error, setError] = useState(null);
useEffect(() => {
// ✅ Subscribe to observable
const subscription = observable$.subscribe({
next: (data) => setValue(data),
error: (err) => setError(err),
complete: () => console.log('Observable completed')
});
// ✅ Cleanup - unsubscribe
return () => {
subscription.unsubscribe();
console.log('Unsubscribed from observable');
};
}, [observable$]);
return { value, error };
};
// Usage
const TickerComponent = () => {
// Create observable that emits even numbers every second
const ticker$ = interval(1000).pipe(
map(n => n * 2),
filter(n => n < 20)
);
const { value, error } = useObservable(ticker$, 0);
if (error) return <p>Error: {error.message}</p>;
return <p>Current value: {value}</p>;
};
Example: Event Emitter Subscription
// Custom event emitter
class EventBus {
constructor() {
this.listeners = new Map();
}
on(event, callback) {
if (!this.listeners.has(event)) {
this.listeners.set(event, new Set());
}
this.listeners.get(event).add(callback);
}
off(event, callback) {
if (this.listeners.has(event)) {
this.listeners.get(event).delete(callback);
}
}
emit(event, data) {
if (this.listeners.has(event)) {
this.listeners.get(event).forEach(callback => callback(data));
}
}
}
const eventBus = new EventBus();
// ✅ Custom hook for event subscription
const useEventBus = (event, handler) => {
useEffect(() => {
// Subscribe to event
eventBus.on(event, handler);
// ✅ Cleanup - unsubscribe
return () => {
eventBus.off(event, handler);
console.log(`Unsubscribed from ${event}`);
};
}, [event, handler]); // Re-subscribe if event or handler changes
};
// Usage
const NotificationListener = () => {
const [notifications, setNotifications] = useState([]);
// ✅ Subscribe to 'notification' events
useEventBus('notification', (data) => {
setNotifications(prev => [...prev, data]);
});
return (
<ul>
{notifications.map((n, i) => <li key={i}>{n.message}</li>)}
</ul>
);
};
Example: Server-Sent Events (SSE) Subscription
const useServerSentEvents = (url) => {
const [events, setEvents] = useState([]);
const [isConnected, setIsConnected] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
// ✅ Create EventSource for SSE
const eventSource = new EventSource(url);
eventSource.onopen = () => {
setIsConnected(true);
setError(null);
console.log('SSE connection opened');
};
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
setEvents(prev => [...prev, data]);
};
eventSource.onerror = (err) => {
setIsConnected(false);
setError('Connection failed');
console.error('SSE error:', err);
};
// ✅ Cleanup - close connection
return () => {
eventSource.close();
console.log('SSE connection closed');
};
}, [url]); // Reconnect if URL changes
return { events, isConnected, error };
};
// Usage
const LiveFeed = () => {
const { events, isConnected, error } = useServerSentEvents('/api/live-feed');
return (
<div>
<p>Status: {isConnected ? 'Connected' : 'Disconnected'}</p>
{error && <p>Error: {error}</p>}
<ul>{events.map((e, i) => <li key={i}>{e.message}</li>)}</ul>
</div>
);
};
Best Practice: When working with subscriptions, use useCallback to memoize event handlers to
prevent unnecessary unsubscribe/resubscribe cycles. Always test that cleanup functions properly unsubscribe to
avoid memory leaks.
5. Race Condition Prevention in State Updates
Common Race Condition Scenarios
| Scenario | Problem | Solution |
|---|---|---|
| Multiple Async Requests | Older request completes after newer one | AbortController, request ID tracking |
| Rapid State Changes | Intermediate states get overwritten | Debouncing, throttling, latest request wins |
| Stale Closure | Effect uses old state value | Functional state updates, proper dependencies |
| Unmounted Component Update | setState called after unmount | Cleanup flag, AbortSignal |
| Concurrent Requests | Multiple requests update same state | Request deduplication, cancel in-flight |
Race Condition Prevention Techniques
| Technique | Implementation | Use Case |
|---|---|---|
| AbortController | Cancel previous requests | Search, autocomplete, pagination |
| Request ID Tracking | Ignore responses from stale requests | Sequential requests with changing params |
| Cleanup Flag | Track if component is mounted | Prevent state updates after unmount |
| Debouncing | Delay request until input settles | Search input, form validation |
| Optimistic Updates | Update UI immediately, rollback on error | Form submissions, toggle actions |
| State Machines | Explicit state transitions | Complex async flows |
Example: AbortController to Prevent Race Conditions
const SearchResults = () => {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!query) {
setResults([]);
return;
}
// ✅ Create AbortController for this search
const abortController = new AbortController();
const searchProducts = async () => {
setLoading(true);
try {
const response = await fetch(`/api/search?q=${query}`, {
signal: abortController.signal // Cancel if query changes
});
const data = await response.json();
// ✅ Only update state if not aborted
setResults(data);
} catch (error) {
if (error.name === 'AbortError') {
console.log('Search aborted for:', query);
return; // Don't update state on abort
}
console.error('Search error:', error);
} finally {
setLoading(false);
}
};
searchProducts();
// ✅ Cleanup - abort previous search when query changes
return () => {
abortController.abort();
};
}, [query]); // New query = abort previous search
return (
<div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
/>
{loading && <p>Searching...</p>}
<ul>{results.map(r => <li key={r.id}>{r.name}</li>)}</ul>
</div>
);
};
Example: Request ID Tracking
const UserProfile = ({ userId }) => {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let isCurrent = true; // ✅ Flag to track if this request is current
let requestId = Symbol(); // Unique ID for this request
const fetchUser = async () => {
setLoading(true);
try {
// Simulate network delay
await new Promise(resolve => setTimeout(resolve,
Math.random() * 2000)); // Random delay 0-2s
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
// ✅ Only update if this is still the current request
if (isCurrent) {
setUser(data);
console.log('Updated user:', userId, requestId.toString());
} else {
console.log('Ignored stale response for:', userId, requestId.toString());
}
} catch (error) {
if (isCurrent) {
console.error('Error fetching user:', error);
}
} finally {
if (isCurrent) {
setLoading(false);
}
}
};
fetchUser();
// ✅ Cleanup - mark request as stale
return () => {
isCurrent = false;
console.log('Marking stale:', userId, requestId.toString());
};
}, [userId]); // New userId = previous request marked stale
if (loading) return <p>Loading...</p>;
return <div>{user?.name}</div>;
};
Example: Debounced Search with Race Condition Prevention
const useDebounce = (value, delay) => {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
};
const DebouncedSearch = () => {
const [searchTerm, setSearchTerm] = useState('');
const [results, setResults] = useState([]);
const debouncedSearch = useDebounce(searchTerm, 300); // 300ms delay
useEffect(() => {
if (!debouncedSearch) {
setResults([]);
return;
}
const abortController = new AbortController();
const performSearch = async () => {
try {
const response = await fetch(
`/api/search?q=${debouncedSearch}`,
{ signal: abortController.signal }
);
const data = await response.json();
setResults(data);
} catch (error) {
if (error.name !== 'AbortError') {
console.error('Search error:', error);
}
}
};
performSearch();
// ✅ Double protection: debounce + abort
return () => abortController.abort();
}, [debouncedSearch]); // Only search when debounced value changes
return (
<div>
<input
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Type to search..."
/>
<p>Searching for: {debouncedSearch}</p>
<ul>{results.map(r => <li key={r.id}>{r.name}</li>)}</ul>
</div>
);
};
Race Condition Warning: Never rely on the order of async operations completing. Always use
AbortController, request ID tracking, or cleanup flags to ensure only the latest or relevant responses update
state. Test with artificial delays to expose race conditions during development.
State and Side Effects Best Practices Summary:
- Always cleanup - Return cleanup function from useEffect for subscriptions/timers
- Abort requests - Use AbortController to cancel stale async requests
- Track mount status - Prevent state updates on unmounted components
- Debounce inputs - Reduce unnecessary API calls for rapid user input
- Handle race conditions - Use request IDs or abort signals
- Sync carefully - Be cautious with infinite loops in useEffect
- Use proper deps - Include all values used in effect to avoid stale closures
- Test cleanup - Verify cleanup functions prevent memory leaks