Loading...

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: A 2025 Guide

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

  1. Infinite Loops

    • Always check dependency arrays
    • Use ESLint's exhaustive-deps rule
    • Consider using useEffectEvent for non-reactive logic
  2. Memory Leaks

    • Always cleanup subscriptions
    • Use AbortController for fetch requests
    • Test cleanup with React DevTools
  3. Race Conditions

    • Implement proper cancellation
    • Use ignore flags for async operations
    • Consider using React Query or SWR

Tools and Resources