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
Here's what each line means:
- at UserProfile (webpack-internal:///./src/components/UserProfile.tsx:16:5): UserProfile's useEffect sets state at line 16 after the component has already unmounted, triggering the memory leak warning.
- at renderWithHooks (node_modules/react-dom/cjs/react-dom.development.js:16305:18): React detects the state update on an unmounted fiber and logs the warning.
- at updateFunctionComponent (node_modules/react-dom/cjs/react-dom.development.js:19588:20): React is trying to process the state update but the component's fiber has already been removed from the tree.
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.
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>;
}
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:
- Notice the error alert or see it in your monitoring tool
- Open the error dashboard and read the stack trace
- Identify the file and line number from the stack trace
- Open your IDE and navigate to the file
- Read the surrounding code to understand context
- Reproduce the error locally
- Identify the root cause
- Write the fix
- Run the test suite locally
- Fix any failing tests
- Write new tests covering the edge case
- Run the full test suite again
- Create a new git branch
- Commit and push your changes
- Open a pull request
- Wait for code review
- Merge and deploy to production
- 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:
- Captures the stack trace and request context
- Pulls the relevant source files from your GitHub repo
- Analyzes the error and understands the code context
- Generates a minimal, verified fix
- Runs your existing test suite
- Pushes through your CI/CD pipeline
- 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)
- Run the test suite locally to confirm no memory leak warnings appear.
- Open a pull request with the AbortController cleanup.
- Wait for CI checks to pass on the PR.
- Have a teammate review and approve the PR.
- 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.