DEV Community

Cover image for useEffect in React: Escape Hatch
Masood Rezazadeh
Masood Rezazadeh

Posted on

useEffect in React: Escape Hatch

If you’re working with React, useEffect is probably a familiar face. It’s a powerful hook for managing side effects, but it’s not a catch-all solution. The React docs describe it as an escape hatch for syncing your component with the outside world, not a tool to throw at every post render task. Misuse it, and you’re in for a world of bugs and performance issues.

💡 What Is useEffect Really For?

useEffect is your connection to the JavaScript world beyond React’s render cycle. It’s designed for side effects that can’t be handled during rendering, such as:

  • Fetching data from an API
  • Subscribing to events (e.g., window resize)
  • Manually updating the DOM
  • Setting timers or intervals
  • Cleaning up resources when a component unmounts

If your component needs to interact with something external to React, useEffect is the go-to tool.

Example: Subscribing to a window event

useEffect(() => {
  function handleResize() {
    setWidth(window.innerWidth);
  }
  window.addEventListener('resize', handleResize);
  return () => window.removeEventListener('resize', handleResize);
}, []);
Enter fullscreen mode Exit fullscreen mode

This code listens for window resize events and ensures proper cleanup when the component unmounts. classic useEffect territory.


🚫 When Not to Use useEffect

One of the biggest mistakes developers make is using useEffect for tasks that belong elsewhere. Here are common misuses and better alternatives.

Calculating Derived State

Don’t: Use useEffect to update state based on other state or props. This adds unnecessary complexity and can trigger extra re-renders.

// Bad
const [fullName, setFullName] = useState('');
useEffect(() => {
  setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);
Enter fullscreen mode Exit fullscreen mode

Do: Compute derived state directly in the render phase for simplicity and performance.

// Good
const fullName = `${firstName} ${lastName}`;
Enter fullscreen mode Exit fullscreen mode

Filtering or Transforming Arrays

Don’t: Store filtered data in state via useEffect. This can slow down your app, especially with large datasets.

// Bad
const [visibleTodos, setVisibleTodos] = useState([]);
useEffect(() => {
  setVisibleTodos(todos.filter(t => !t.completed));
}, [todos]);
Enter fullscreen mode Exit fullscreen mode

Do: Filter data directly in render to keep things efficient.

// Good
const visibleTodos = todos.filter(t => !t.completed);
Enter fullscreen mode Exit fullscreen mode

Handling User Events

Don’t: Put user-triggered logic in useEffect. It makes your code harder to follow and debug.

// Bad
useEffect(() => {
  if (formSubmitted) {
    sendAnalytics();
  }
}, [formSubmitted]);
Enter fullscreen mode Exit fullscreen mode

Do: Handle user events in event handlers for clarity.

// Good
function handleSubmit() {
  sendAnalytics();
  // other logic...
}
Enter fullscreen mode Exit fullscreen mode

Resetting State on Prop Change

Don’t: Use useEffect to reset state when props change. This can lead to unexpected behavior in concurrent rendering.

// Bad
useEffect(() => {
  setComment('');
}, [userId]);
Enter fullscreen mode Exit fullscreen mode

Do: Use a key prop to reset component state by forcing a remount.

// Good
<Profile key={userId} userId={userId} />
Enter fullscreen mode Exit fullscreen mode

🚀 Level Up

Here are some tips to keep your code robust and efficient.

Master the Dependency Array

The dependency array controls when your effect runs. Always include every value from outside the effect that’s used inside it. Skipping dependencies can hide bugs. For functions or objects passed as dependencies, use useCallback or useMemo to prevent unnecessary re-runs.

Avoid Infinite Loops and Stale Closures

Infinite loops occur when an effect updates state that triggers itself again. Check your dependencies to avoid this. Stale closures (where an effect uses outdated values) can be tricky with async operations. Use refs to access the latest values when needed.

Cleanup Is Essential

Always return a cleanup function for subscriptions, timers, or other external resources to prevent memory leaks. In React Strict Mode, effects run twice in development, so ensure your cleanup is idempotent (safe to run multiple times).

Example: Cleaning up a fetch request

useEffect(() => {
  const controller = new AbortController();
  fetch('/api/data', { signal: controller.signal });
  return () => controller.abort();
}, []);
Enter fullscreen mode Exit fullscreen mode

Extract Custom Hooks for Reuse

If you’re repeating effect logic, extract it into a custom hook for cleaner, testable code.

function useOnlineStatus() {
  const [isOnline, setIsOnline] = useState(navigator.onLine);
  useEffect(() => {
    function updateStatus() {
      setIsOnline(navigator.onLine);
    }
    window.addEventListener('online', updateStatus);
    window.addEventListener('offline', updateStatus);
    return () => {
      window.removeEventListener('online', updateStatus);
      window.removeEventListener('offline', updateStatus);
    };
  }, []);
  return isOnline;
}
Enter fullscreen mode Exit fullscreen mode

useEffect vs useLayoutEffect

useEffect runs after the browser paints, ideal for non-visual tasks like data fetching. useLayoutEffect runs synchronously before painting, perfect for DOM measurements or visual tweaks that must happen before the user sees the screen.

Handle Race Conditions in Data Fetching

When fetching data, race conditions can occur if multiple requests are sent before the first completes. Use a flag or abort signal to ignore stale responses.

useEffect(() => {
  let ignore = false;
  fetchData().then(result => {
    if (!ignore) setData(result);
  });
  return () => { ignore = true; };
}, [someDependency]);
Enter fullscreen mode Exit fullscreen mode

Server vs Client Boundaries

useEffect only runs in the browser, not during server-side rendering. For logic needed on both server and client, handle it outside effects or in your data fetching layer.


🧠 Better Data Fetching: TanStack Query

While useEffect is versatile, specialized libraries can handle certain side effects more effectively, reducing boilerplate and potential errors.

For data fetching, TanStack Query (formerly React Query) simplifies server state management with features like automatic caching, background updates, and built in error handling, making it a cleaner alternative to raw useEffect for API calls.

import { useQuery } from '@tanstack/react-query';

function MyComponent() {
  const { data, isLoading, error } = useQuery({
    queryKey: ['todos'],
    queryFn: () => fetch('/api/todos').then(res => res.json()),
  });

  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;

  return (
    <ul>
      {data.map(todo => (
        <li key={todo.id}>{todo.title}</li>
      ))}
    </ul>
  );
}
Enter fullscreen mode Exit fullscreen mode

TanStack Query handles caching and refetching out of the box, which would require significant manual effort with useEffect. Learn more at TanStack Query Docs.


🧹 Final Thoughts

useEffect is a powerful tool, but it’s not a hammer for every nail. Use it for syncing with external systems, and keep derived state, event handling, and data transformations in the render phase. Master dependency arrays, prioritize cleanup, and extract reusable logic into custom hooks.

For specific tasks like data fetching, libraries like TanStack Query can save you time and reduce bugs. By choosing the right tool for the job, you’ll build React apps that are fast, reliable, and a pleasure to maintain. Try these patterns in your next project and see how they streamline your workflow!

I highly recommend reading the React team’s amazing article, You Might Not Need an Effect, to better understand detailed use cases and common pitfalls.

Top comments (0)