Mastering Side Effects in Modern React: A 2025 Guide
Learn advanced patterns for managing side effects in React applications, including custom hooks, performance optimization, and real-world examples using TypeScript.

Mastering Side Effects in Modern React
This guide has been updated with React 19 features, TypeScript best practices, and modern side effect patterns.
Side effects are a critical part of React applications, yet they're often misunderstood and misused. In this comprehensive guide, we'll explore how to master side effects for building robust, maintainable React applications in 2025.
Understanding Side Effects in React
Side effects occur when a component needs to interact with the outside world:
- API calls and data fetching
- DOM manipulations
- Analytics and logging
- Timers and intervals
- WebSocket connections
- Third-party integrations
The Evolution of Side Effects in React
typescript
useEffect(() => {}, [dependencies]);
useEffectEvent(signal => {
signal.addEventListener("abort", () => {});
});
Best Practices for Side Effects
1. Use Custom Hooks for Reusable Effects
Instead of repeating effect logic, create custom hooks:
typescript
function useApi<T>(url: string) {
const [data, setData] = useState<T | null>(null);
const [error, setError] = useState<Error | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let ignore = false;
async function fetchData() {
try {
const response = await fetch(url);
const json = await response.json();
if (!ignore) {
setData(json);
}
} catch (err) {
if (!ignore) {
setError(err as Error);
}
} finally {
if (!ignore) {
setLoading(false);
}
}
}
fetchData();
return () => {
ignore = true;
};
}, [url]);
return { data, error, loading };
}
function UserProfile({ userId }: { userId: string }) {
const { data, error, loading } = useApi<User>(`/api/users/${userId}`);
if (loading) return <Spinner />;
if (error) return <ErrorMessage error={error} />;
if (!data) return null;
return <UserCard user={data} />;
}
2. Handle Cleanup Properly
Memory leaks are a common issue with side effects. Here's how to handle them:
typescript
function EventListener({
eventName,
handler,
}: {
eventName: string;
handler: (event: Event) => void;
}) {
useEffect(() => {
window.addEventListener(eventName, handler);
return () => {
window.removeEventListener(eventName, handler);
};
}, [eventName, handler]);
return null;
}
3. Optimize Performance with Dependencies
Be strategic with effect dependencies:
typescript
function SearchResults({ query, filters }: { query: string; filters: SearchFilters }) {
const memoizedFilters = useMemo(
() => ({
...filters,
timestamp: Date.now(),
}),
[filters],
);
const handleSearch = useCallback(async () => {
const results = await searchAPI(query, memoizedFilters);
}, [query, memoizedFilters]);
useEffect(() => {
handleSearch();
}, [handleSearch]);
return;
}
4. Use Effect Events for Non-Reactive Logic
React 19 introduces Effect Events for handling non-reactive parts of effects:
typescript
function ChatRoom({ roomId }: { roomId: string }) {
const logConnection = useEffectEvent((connectedRoomId: string) => {
analytics.log("connected", {
roomId: connectedRoomId,
theme: user.preferences.theme,
});
});
useEffect(() => {
const connection = createConnection(roomId);
connection.connect();
logConnection(roomId);
return () => connection.disconnect();
}, [roomId]);
return;
}
Common Side Effect Patterns
1. Data Synchronization
Keep local state in sync with external data:
typescript
function SyncedPreferences({ userId }: { userId: string }) {
const [preferences, setPreferences] = useState<UserPreferences>();
useEffect(() => {
const stored = localStorage.getItem(`prefs_${userId}`);
if (stored) {
setPreferences(JSON.parse(stored));
}
}, [userId]);
useEffect(() => {
if (preferences) {
localStorage.setItem(`prefs_${userId}`, JSON.stringify(preferences));
}
}, [userId, preferences]);
return;
}
2. Subscription Management
Handle subscriptions cleanly:
typescript
function LiveData({ source }: { source: string }) {
const [data, setData] = useState<Data>();
useEffect(() => {
const subscription = websocket.subscribe(source, {
next: newData => setData(newData),
error: err => console.error(err),
});
return () => subscription.unsubscribe();
}, [source]);
return;
}
3. Animation Control
Manage animations effectively:
typescript
function FadeIn({ children }: { children: React.ReactNode }) {
const elementRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const element = elementRef.current;
if (!element) return;
const animation = element.animate([
{ opacity: 0 },
{ opacity: 1 }
], {
duration: 500,
easing: 'ease-in-out'
});
return () => animation.cancel();
}, []);
return (
<div ref={elementRef}>
{children}
</div>
);
}
Advanced Patterns
1. Concurrent Mode Compatibility
Make effects work well with React's concurrent features:
typescript
function AsyncComponent() {
const [data, setData] = useState(null);
useEffect(() => {
let ignore = false;
async function startTransition() {
await new Promise(resolve => setTimeout(resolve, 0));
if (!ignore) {
setData(/* ... */);
}
}
startTransition();
return () => {
ignore = true;
};
}, []);
return;
}
2. Error Boundaries for Effects
Handle effect errors gracefully:
typescript
class EffectErrorBoundary extends React.Component {
state = { hasError: false };
static getDerivedStateFromError() {
return { hasError: true };
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
console.error('Effect error:', error, info);
}
render() {
if (this.state.hasError) {
return <ErrorFallback />;
}
return this.props.children;
}
}
Testing Side Effects
Write robust tests for components with side effects:
typescript
import { act, renderHook } from "@testing-library/react";
test("useApi hook fetches and updates state", async () => {
const mockData = { id: 1, name: "Test" };
global.fetch = jest.fn().mockResolvedValue({
json: () => Promise.resolve(mockData),
});
const { result, waitForNextUpdate } = renderHook(() => useApi("/api/test"));
expect(result.current.loading).toBe(true);
await waitForNextUpdate();
expect(result.current.data).toEqual(mockData);
expect(result.current.loading).toBe(false);
});
Performance Monitoring
Track effect performance in production:
typescript
function useMonitoredEffect(effect: EffectCallback, deps: DependencyList, name: string) {
useEffect(() => {
const start = performance.now();
const cleanup = effect();
const duration = performance.now() - start;
analytics.trackTiming("effect_duration", duration, {
name,
deps: JSON.stringify(deps),
});
return cleanup;
}, deps);
}
Common Pitfalls
-
Infinite Loops
- Always check dependency arrays
- Use ESLint's exhaustive-deps rule
- Consider using useEffectEvent for non-reactive logic
-
Memory Leaks
- Always cleanup subscriptions
- Use AbortController for fetch requests
- Test cleanup with React DevTools
-
Race Conditions
- Implement proper cancellation
- Use ignore flags for async operations
- Consider using React Query or SWR
Tools and Resources
- React DevTools Profiler
- Effect Hook ESLint Plugin
- React Query for data fetching
- SWR for remote data synchronization