How It Works Features Pricing Blog Error Guides
Log In Start Free Trial
React · TypeScript/React

Fix Warning: Can't perform a React state update on an unmounted component. This is a no-op, but it indicates a memory leak in your application. in React

This warning fires when an async operation (like a fetch or timer) completes after the component has unmounted and tries to call setState. The update is ignored but signals a memory leak. Fix it by returning a cleanup function from useEffect that cancels the pending operation, using an AbortController for fetch requests or clearing timers.

Reading the Stack Trace

Warning: Can't perform a React state update on an unmounted component. at UserProfile (webpack-internal:///./src/components/UserProfile.tsx:16:5) at renderWithHooks (node_modules/react-dom/cjs/react-dom.development.js:16305:18) at updateFunctionComponent (node_modules/react-dom/cjs/react-dom.development.js:19588:20) at beginWork (node_modules/react-dom/cjs/react-dom.development.js:21601:16) at HTMLUnknownElement.callCallback (node_modules/react-dom/cjs/react-dom.development.js:4164:14) at Object.invokeGuardedCallbackDev (node_modules/react-dom/cjs/react-dom.development.js:4213:16) at invokeGuardedCallback (node_modules/react-dom/cjs/react-dom.development.js:4277:31) at beginWork$1 (node_modules/react-dom/cjs/react-dom.development.js:27451:7) at performUnitOfWork (node_modules/react-dom/cjs/react-dom.development.js:26557:12) at workLoopSync (node_modules/react-dom/cjs/react-dom.development.js:26466:5)

Here's what each line means:

Common Causes

1. Fetch without AbortController

A fetch call in useEffect completes after the component unmounts due to navigation, and the .then() callback calls setState on the now-unmounted component.

export default function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(r => r.json())
      .then(data => setUser(data));
  }, [userId]);

  return user ? <h1>{user.name}</h1> : <p>Loading...</p>;
}

2. setInterval without cleanup

A setInterval is created in useEffect but never cleared, so it keeps calling setState after the component unmounts.

export default function LiveClock() {
  const [time, setTime] = useState(new Date());

  useEffect(() => {
    setInterval(() => setTime(new Date()), 1000);
  }, []);

  return <p>{time.toLocaleTimeString()}</p>;
}

3. WebSocket without disconnect

A WebSocket connection is opened in useEffect but never closed, so messages received after unmount trigger setState calls.

export default function LiveFeed() {
  const [messages, setMessages] = useState([]);

  useEffect(() => {
    const ws = new WebSocket('wss://api.example.com/feed');
    ws.onmessage = (e) => setMessages(prev => [...prev, JSON.parse(e.data)]);
  }, []);

  return <ul>{messages.map(m => <li key={m.id}>{m.text}</li>)}</ul>;
}

The Fix

Create an AbortController and pass its signal to the fetch call. Return a cleanup function from useEffect that calls controller.abort(). When the component unmounts or userId changes, the in-flight fetch is cancelled and the .then() callback never fires, preventing the stale setState call.

Before (broken)
export default function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(r => r.json())
      .then(data => setUser(data));
  }, [userId]);

  return user ? <h1>{user.name}</h1> : <p>Loading...</p>;
}
After (fixed)
export default function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    const controller = new AbortController();

    fetch(`/api/users/${userId}`, { signal: controller.signal })
      .then(r => r.json())
      .then(data => setUser(data))
      .catch(err => {
        if (err.name !== 'AbortError') throw err;
      });

    return () => controller.abort();
  }, [userId]);

  return user ? <h1>{user.name}</h1> : <p>Loading...</p>;
}

Testing the Fix

import { render, screen, waitFor } from '@testing-library/react';
import UserProfile from './UserProfile';

global.fetch = jest.fn();

describe('UserProfile', () => {
  beforeEach(() => {
    (fetch as jest.Mock).mockResolvedValue({
      json: () => Promise.resolve({ name: 'Alice' }),
    });
  });

  it('renders loading state initially', () => {
    render(<UserProfile userId="1" />);
    expect(screen.getByText('Loading...')).toBeInTheDocument();
  });

  it('renders user name after fetch', async () => {
    render(<UserProfile userId="1" />);
    await waitFor(() => {
      expect(screen.getByText('Alice')).toBeInTheDocument();
    });
  });

  it('aborts fetch on unmount', () => {
    const abortSpy = jest.spyOn(AbortController.prototype, 'abort');
    const { unmount } = render(<UserProfile userId="1" />);
    unmount();
    expect(abortSpy).toHaveBeenCalled();
    abortSpy.mockRestore();
  });
});

Run your tests:

npm test

Pushing Through CI/CD

git checkout -b fix/react-useeffect-cleanup-error,git add src/components/UserProfile.tsx src/components/__tests__/UserProfile.test.tsx,git commit -m "fix: add AbortController cleanup to useEffect fetch",git push origin fix/react-useeffect-cleanup-error

Your CI config should look something like this:

name: CI
on:
  pull_request:
    branches: [main]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - run: npm test -- --coverage
      - run: npm run build

The Full Manual Process: 18 Steps

Here's every step you just went through to fix this one bug:

  1. Notice the error alert or see it in your monitoring tool
  2. Open the error dashboard and read the stack trace
  3. Identify the file and line number from the stack trace
  4. Open your IDE and navigate to the file
  5. Read the surrounding code to understand context
  6. Reproduce the error locally
  7. Identify the root cause
  8. Write the fix
  9. Run the test suite locally
  10. Fix any failing tests
  11. Write new tests covering the edge case
  12. Run the full test suite again
  13. Create a new git branch
  14. Commit and push your changes
  15. Open a pull request
  16. Wait for code review
  17. Merge and deploy to production
  18. Monitor production to confirm the error is resolved

Total time: 30-60 minutes. For one bug.

Or Let bugstack Fix It in Under 2 minutes

Every step above? bugstack does it automatically.

Step 1: Install the SDK

npm install bugstack-sdk

Step 2: Initialize

import { initBugStack } from 'bugstack-sdk'

initBugStack({ apiKey: process.env.BUGSTACK_API_KEY })

Step 3: There is no step 3.

bugstack handles everything from here:

  1. Captures the stack trace and request context
  2. Pulls the relevant source files from your GitHub repo
  3. Analyzes the error and understands the code context
  4. Generates a minimal, verified fix
  5. Runs your existing test suite
  6. Pushes through your CI/CD pipeline
  7. Deploys to production (or opens a PR for review)

Time from error to fix deployed: Under 2 minutes.

Human involvement: zero.

Try bugstack Free →

No credit card. 5-minute setup. Cancel anytime.

Deploying the Fix (Manual Path)

  1. Run the test suite locally to confirm no memory leak warnings appear.
  2. Open a pull request with the AbortController cleanup.
  3. Wait for CI checks to pass on the PR.
  4. Have a teammate review and approve the PR.
  5. Merge to main and verify no memory leak warnings in staging.

Frequently Asked Questions

BugStack validates that all useEffect hooks have proper cleanup functions, runs your test suite including unmount scenarios, and confirms no memory leak warnings before marking it safe.

BugStack never pushes directly to production. Every fix goes through a pull request with full CI checks, so your team can review it before merging.

React 18 removed this specific warning because it caused more confusion than it solved. However, the underlying memory leak still exists, so cleanup is still best practice.

A boolean isMounted flag works but is considered an anti-pattern. AbortController actually cancels the network request, saving bandwidth and preventing the response from being processed at all.