Skip to content
ExponaBLOG
REACT & NEXT.JS · DAY 007 / 100

Clean up your effects: a practical React & Next.js guide

Every subscription you open is one you must close.

Expona Admin8 min read

Clean up your effects

Every subscription you open is one you must close. I've debugged enough production issues to know that forgetting cleanup in React effects is one of the most insidious bugs you'll encounter—it doesn't crash your app, it silently bleeds memory and resources until performance degrades or unexpected side effects pile up. On day 7 of building with React and Next.js, we're tackling the cleanup function: the return statement inside useEffect that prevents your component from leaving loose threads.

Return a cleanup function from useEffect

The simplest and most overlooked feature of useEffect is that you can return a function. React calls that function before the component unmounts or before the effect runs again. This is your chance to undo whatever the effect did.

tsx
useEffect(() => {
  console.log('Effect running');
  
  return () => {
    console.log('Cleanup running');
  };
}, []);

When you mount the component, you'll see "Effect running". When you unmount it, you'll see "Cleanup running". If your dependency array changes and the effect re-runs, cleanup runs first, then the effect runs again.

This pattern is straightforward but the consequences of skipping it are real. Imagine a modal component that sets up event listeners:

tsx
useEffect(() => {
  const handleEscape = (e) => {
    if (e.key === 'Escape') closeModal();
  };
  window.addEventListener('keydown', handleEscape);
  // Missing cleanup = listener stays forever
}, []);

Open and close this modal ten times and you've got ten identical listeners sitting on the window object. Each one calls closeModal. The effect works fine the first time, but you've created waste.

The cleanup function fixes it:

tsx
useEffect(() => {
  const handleEscape = (e) => {
    if (e.key === 'Escape') closeModal();
  };
  window.addEventListener('keydown', handleEscape);
  
  return () => {
    window.removeEventListener('keydown', handleEscape);
  };
}, []);

Now each time the effect cleans up, the listener is removed. Clean state, every time.

Unsubscribe sockets, clear timers, abort fetches

Different resources need different cleanup strategies. Let me walk through the most common ones you'll hit in production code.

WebSocket subscriptions

WebSockets are stateful connections that drain resources if left open. A real-time trading dashboard component might subscribe to price updates:

tsx
useEffect(() => {
  const socket = new WebSocket('wss://prices.example.com');
  
  socket.onmessage = (event) => {
    setPrices(JSON.parse(event.data));
  };
  
  return () => {
    socket.close();
  };
}, []);

Without the cleanup, navigating away from the dashboard leaves the socket open. The server keeps pushing data into the void. Multiply that across a hundred users and you've got a resource leak on both client and server.

Timers and intervals

A notification component with a countdown:

tsx
useEffect(() => {
  const timerId = setTimeout(() => {
    dismissNotification();
  }, 3000);
  
  return () => {
    clearTimeout(timerId);
  };
}, []);

If you unmount the component before 3 seconds, the timeout still fires and calls dismissNotification on an unmounted component. React warns you about this ("Can't perform a React state update on an unmounted component"). The cleanup prevents that:

tsx
useEffect(() => {
  const intervalId = setInterval(() => {
    setCount((c) => c + 1);
  }, 1000);
  
  return () => {
    clearInterval(intervalId);
  };
}, []);

AbortController for in-flight requests

This is the modern pattern for cancelling fetch requests. If a component fetches data but unmounts before the response arrives, you want to abort that fetch:

tsx
useEffect(() => {
  const ctrl = new AbortController();
  
  fetch(`/api/users/${userId}`, { signal: ctrl.signal })
    .then((res) => res.json())
    .then((data) => setUser(data))
    .catch((err) => {
      if (err.name !== 'AbortError') {
        console.error('Fetch failed:', err);
      }
    });
  
  return () => {
    ctrl.abort();
  };
}, [userId]);

The AbortController's signal is passed to fetch. When the effect cleans up, ctrl.abort() cancels the request. If the component unmounts while fetching, the promise rejection is suppressed (it becomes an AbortError, which we ignore) and no state update happens.

This is especially important in Next.js when you're building a fast-moving user experience. Users click fast, components mount and unmount quickly. Without abort, you end up with responses landing on unmounted components and React filling your console with warnings.

Strict Mode double-invokes effects to find leaks

React's StrictMode is a development-only tool that intentionally double-invokes effects to surface cleanup bugs. If you're not seeing the cleanup behavior in your app, it might be because the leak is hiding under normal usage patterns.

Here's what happens in development with StrictMode:

  1. Component mounts
  2. Effect runs
  3. Cleanup runs (immediately, in dev only)
  4. Effect runs again
  5. Component unmounts
  6. Cleanup runs

This is intentional. React is simulating a scenario where a component mounts, unmounts, and mounts again instantly. If your cleanup doesn't work, the double-invocation will expose it.

For example, if you're setting up a listener without cleanup:

tsx
useEffect(() => {
  window.addEventListener('resize', handleResize);
  // No cleanup
}, []);

In StrictMode, the event listener gets added twice, cleaned up (not), and added twice more. By the time the component truly unmounts, you've got duplicate listeners that will never fire again (because the component is gone) but they're still there in memory.

Proper cleanup makes StrictMode a powerful ally:

tsx
useEffect(() => {
  window.addEventListener('resize', handleResize);
  
  return () => {
    window.removeEventListener('resize', handleResize);
  };
}, []);

Now the double-invocation is harmless. Add, remove, add, remove. No leaks.

If you're not seeing your effects double-invoked, check that StrictMode is wrapping your app in development:

tsx
// pages/_app.tsx or app.tsx in Next.js
import { StrictMode } from 'react';

export default function App({ Component, pageProps }) {
  return (
    
      
    
  );
}

AbortController for in-flight requests

I've touched on AbortController above, but it deserves deeper treatment because it's the modern standard and I see it done wrong frequently.

The old approach was to use a flag:

tsx
useEffect(() => {
  let isMounted = true;
  
  fetch(url)
    .then((res) => res.json())
    .then((data) => {
      if (isMounted) setData(data);
    });
  
  return () => {
    isMounted = false;
  };
}, [url]);

This works but it doesn't actually cancel the fetch. The request still completes on the server, the response still downloads, and your code just ignores it. Wasteful.

AbortController cancels the request at the network level:

tsx
useEffect(() => {
  const ctrl = new AbortController();
  
  fetch(url, { signal: ctrl.signal })
    .then((res) => res.json())
    .then((data) => setData(data))
    .catch((err) => {
      if (err.name !== 'AbortError') {
        // Real error, not a cancellation
        setError(err);
      }
    });
  
  return () => {
    ctrl.abort();
  };
}, [url]);

When you call ctrl.abort(), the fetch promise rejects with an AbortError. You catch it, check the name, and only log real errors.

This pattern scales well. In a data table that refetches when filters change, every filter change aborts the previous request and starts a new one. The user sees the latest data, not stale responses from older requests.

Common mistakes

Missing cleanup entirely. The most frequent mistake. You add an effect, set up a listener or subscription, and forget to return a cleanup function. It works for a while and then you notice memory creeping up or weird behavior after repeated interactions.

Cleanup that depends on stale values. If your cleanup needs to reference a value from the effect, make sure it's captured correctly. This is rare because cleanup is defined inside the effect (so it naturally captures the right scope), but it can happen if you're storing references outside:

tsx
let subscription;

useEffect(() => {
  subscription = observable.subscribe(callback);
  return () => subscription.unsubscribe();
}, []);

This works but it's risky. If the effect runs again, subscription gets overwritten before cleanup runs. Better to keep it local:

tsx
useEffect(() => {
  const subscription = observable.subscribe(callback);
  return () => subscription.unsubscribe();
}, []);

Forgetting dependencies. If your effect uses values that can change, include them in the dependency array. If you don't, the effect uses stale values and cleanup doesn't run when needed:

tsx
// Bad: userId changes but effect doesn't re-run
useEffect(() => {
  fetch(`/api/users/${userId}`);
  // Missing userId from deps
}, []);

// Good
useEffect(() => {
  fetch(`/api/users/${userId}`);
}, [userId]);

Cleanup that throws. If your cleanup function throws an error, it can swallow real errors and make debugging harder. Wrap cleanup in try-catch if there's any risk:

tsx
return () => {
  try {
    socket.close();
  } catch (err) {
    console.error('Socket close failed:', err);
  }
};

Key takeaways

  • Every effect that sets up a subscription, listener, timer, or request should return a cleanup function.
  • Use AbortController with fetch to cancel in-flight requests when a component unmounts.
  • Remove event listeners, close WebSockets, and clear timers in your cleanup to prevent resource leaks and state updates on unmounted components.
  • React's StrictMode double-invokes effects in development to catch cleanup bugs—use it actively during development.
  • Keep your cleanup logic as simple as possible and co-locate it with the setup it cleans up.
  • Watch your dependencies carefully; if they change but the effect doesn't re-run, cleanup won't run when it should.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles