Custom useIsMounted Hook: Prevent Memory Leaks (2026)
If you've been working with React for any length of time, you've probably seen this warning in your console:
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.
This warning is React's way of saying: "You're trying to update state on a component that no longer exists." It typically happens when you perform an async operation (like fetching data) and try to update state after the component unmounts.
The traditional solution was a useIsMounted hook that tracks whether the component is still mounted. However, React's own documentation now recommends other patterns. In this guide, we'll explore the useIsMounted approach, understand its trade-offs, and see modern alternatives that solve the underlying problem more elegantly.
Table of Contents
- Understanding the Memory Leak Warning
- Building a Basic useIsMounted Hook
- The Problem with useIsMounted
- Modern Alternative Patterns
- When useIsMounted Still Makes Sense
- Practical Examples: From Bad to Good
- Advanced: Cleanup with AbortController
- FAQ
Understanding the Memory Leak Warning
Before we build anything, let's understand what's actually happening. The memory leak warning appears because of this sequence:
- Component mounts and renders
- Component starts an async operation (like
fetch) - Component unmounts before the async operation completes
- The async operation completes and tries to update state
- React detects that state is being set on an unmounted component
Here's a real-world example that triggers the warning:
TypeScript Example (Problematic)
import { useEffect, useState } from 'react';
interface User {
id: number;
name: string;
}
export function UserProfile({ userId }: { userId: number }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let isMounted = true;
// Async operation that takes time
const fetchUser = async () => {
const response = await fetch(`/api/users/${userId}`);
const userData = await response.json();
// This triggers the warning if component unmounts before this line
if (isMounted) {
setUser(userData);
setLoading(false);
}
};
fetchUser();
// Cleanup function
return () => {
isMounted = false;
};
}, [userId]);
if (loading) return <div>Loading...</div>;
return <div>{user?.name}</div>;
}
The workaround is creating a local isMounted variable and checking it before updating state. This works, but it's boilerplate that you repeat in many components. That's where a custom hook comes in—to extract this pattern.
Building a Basic useIsMounted Hook
Here's the simplest version of a useIsMounted hook that extracts the pattern we just saw:
TypeScript Version
import { useEffect, useRef } from 'react';
export function useIsMounted() {
const isMountedRef = useRef(true);
useEffect(() => {
return () => {
isMountedRef.current = false;
};
}, []);
return isMountedRef.current;
}
JavaScript Version
import { useEffect, useRef } from 'react';
export function useIsMounted() {
const isMountedRef = useRef(true);
useEffect(() => {
return () => {
isMountedRef.current = false;
};
}, []);
return isMountedRef.current;
}
The hook uses useRef to create a mutable reference that persists across renders. The useEffect with an empty dependency array runs once when the component mounts, and its cleanup function runs when the component unmounts. At that point, we set isMountedRef.current to false.
Now you can use it like this:
TypeScript Usage
import { useEffect, useState } from 'react';
import { useIsMounted } from './useIsMounted';
interface User {
id: number;
name: string;
}
export function UserProfile({ userId }: { userId: number }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const isMounted = useIsMounted();
useEffect(() => {
const fetchUser = async () => {
const response = await fetch(`/api/users/${userId}`);
const userData = await response.json();
// Now we check isMounted before updating state
if (isMounted) {
setUser(userData);
setLoading(false);
}
};
fetchUser();
}, [userId, isMounted]);
if (loading) return <div>Loading...</div>;
return <div>{user?.name}</div>;
}
JavaScript Usage
import { useEffect, useState } from 'react';
import { useIsMounted } from './useIsMounted';
export function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const isMounted = useIsMounted();
useEffect(() => {
const fetchUser = async () => {
const response = await fetch(`/api/users/${userId}`);
const userData = await response.json();
if (isMounted) {
setUser(userData);
setLoading(false);
}
};
fetchUser();
}, [userId, isMounted]);
if (loading) return <div>Loading...</div>;
return <div>{user?.name}</div>;
}
This eliminates the warning. But here's the critical issue: we're still checking a flag before updating state, which is really just hiding the problem rather than solving it.
The Problem with useIsMounted
The React team actually discourages using useIsMounted in modern React. Here's why:
1. It's a Band-Aid, Not a Solution
When you check if (isMounted) before calling setState, you're skipping state updates. The data is still being fetched and processed; you're just ignoring it. This wastes resources and doesn't truly solve the underlying issue.
2. It Creates a False Sense of Security
Developers sometimes think "okay, I'm checking isMounted, so I'm safe." But the real problem is architectural. If you need to check whether a component is mounted, your data flow is probably not structured correctly.
3. It Doesn't Handle Race Conditions
Consider this scenario:
- User navigates away from the profile (component unmounts)
- Fetch request for the old userId completes
- New fetch request for the new userId starts
- You check
isMountedand skip the old userId's data - But what if the new fetch completes before the old one?
Now the UI shows stale data. The useIsMounted flag can't help you here.
4. Subtle Memory Leaks Still Exist
Even with useIsMounted, if the cleanup function doesn't properly cancel ongoing operations, you're still leaking resources in the background. The operation just silently completes without updating the UI.
Modern Alternative Patterns
The React team recommends these approaches instead:
Pattern 1: AbortController (The Modern Way)
AbortController is a native JavaScript API that lets you cancel fetch requests. This is the preferred modern approach:
TypeScript Version
import { useEffect, useState } from 'react';
interface User {
id: number;
name: string;
}
export function UserProfile({ userId }: { userId: number }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
// Create an AbortController for this effect
const controller = new AbortController();
const fetchUser = async () => {
try {
const response = await fetch(`/api/users/${userId}`, {
signal: controller.signal,
});
if (!response.ok) {
throw new Error('Failed to fetch user');
}
const userData = await response.json();
setUser(userData);
setLoading(false);
} catch (err) {
// AbortError is thrown when the request is cancelled
if (err instanceof Error && err.name === 'AbortError') {
console.log('Fetch was cancelled');
return;
}
setError(err instanceof Error ? err.message : 'Unknown error');
setLoading(false);
}
};
fetchUser();
// Cleanup: cancel the fetch if component unmounts
return () => {
controller.abort();
};
}, [userId]);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return <div>{user?.name}</div>;
}
JavaScript Version
import { useEffect, useState } from 'react';
export function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController();
const fetchUser = async () => {
try {
const response = await fetch(`/api/users/${userId}`, {
signal: controller.signal,
});
if (!response.ok) {
throw new Error('Failed to fetch user');
}
const userData = await response.json();
setUser(userData);
setLoading(false);
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') {
console.log('Fetch was cancelled');
return;
}
setError(err instanceof Error ? err.message : 'Unknown error');
setLoading(false);
}
};
fetchUser();
return () => {
controller.abort();
};
}, [userId]);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return <div>{user?.name}</div>;
}
This is much better than checking isMounted. When the component unmounts, controller.abort() actually cancels the fetch request. The request never completes, so there's no dangling callback trying to update state.
Pattern 2: React Query or SWR (The Practical Way)
For real-world applications, especially at companies like Tencent or Alibaba, using a data-fetching library is the best approach:
import { useQuery } from '@tanstack/react-query';
interface User {
id: number;
name: string;
}
export function UserProfile({ userId }: { userId: number }) {
const { data: user, isLoading, error } = useQuery<User>({
queryKey: ['user', userId],
queryFn: async () => {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) throw new Error('Failed to fetch');
return response.json();
},
});
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return <div>{user?.name}</div>;
}
React Query handles all the complexity for you: request cancellation, caching, stale data, race conditions, retry logic, and more. This is what you should use in production applications.
When useIsMounted Still Makes Sense
There are legitimate cases where a useIsMounted hook is useful, even in modern React:
1. Non-Cancellable Async Operations
Some APIs can't be cancelled (not all libraries support abort signals). For these cases, checking isMounted is a pragmatic solution:
TypeScript Example
import { useEffect, useState } from 'react';
import { useIsMounted } from './useIsMounted';
export function NonCancellableOperation() {
const [result, setResult] = useState(null);
const isMounted = useIsMounted();
useEffect(() => {
// Some third-party library that doesn't support cancellation
expensiveOperationWithNoAbort().then((data) => {
if (isMounted) {
setResult(data);
}
});
}, [isMounted]);
return <div>{result}</div>;
}
2. Event Listener Cleanup Edge Cases
Sometimes you have complex event listener scenarios where isMounted helps manage state updates:
import { useEffect, useRef } from 'react';
import { useIsMounted } from './useIsMounted';
export function WebSocketComponent() {
const wsRef = useRef<WebSocket | null>(null);
const isMounted = useIsMounted();
useEffect(() => {
const ws = new WebSocket('wss://api.example.com');
ws.onmessage = (event) => {
if (isMounted) {
// Process message safely
console.log('Message received:', event.data);
}
};
wsRef.current = ws;
return () => {
ws.close();
};
}, [isMounted]);
return <div>WebSocket connected</div>;
}
3. Defensive Programming in Legacy Code
If you're working with a legacy codebase that has lots of async operations and you want a quick fix without refactoring everything, useIsMounted is a reasonable intermediate step.
Practical Examples: From Bad to Good
Let's see how to improve a real data-fetching component using different approaches:
❌ Bad: No Cleanup
export function BadComponent({ itemId }: { itemId: number }) {
const [data, setData] = useState(null);
useEffect(() => {
fetch(`/api/items/${itemId}`)
.then(r => r.json())
.then(setData); // Memory leak warning!
}, [itemId]);
return <div>{data?.name}</div>;
}
🟡 Okay: Using useIsMounted
export function OkayComponent({ itemId }: { itemId: number }) {
const [data, setData] = useState(null);
const isMounted = useIsMounted();
useEffect(() => {
fetch(`/api/items/${itemId}`)
.then(r => r.json())
.then(data => {
if (isMounted) setData(data);
});
}, [itemId, isMounted]);
return <div>{data?.name}</div>;
}
✅ Good: Using AbortController
export function GoodComponent({ itemId }: { itemId: number }) {
const [data, setData] = useState(null);
useEffect(() => {
const controller = new AbortController();
fetch(`/api/items/${itemId}`, { signal: controller.signal })
.then(r => r.json())
.then(setData)
.catch(err => {
if (err.name !== 'AbortError') {
console.error(err);
}
});
return () => controller.abort();
}, [itemId]);
return <div>{data?.name}</div>;
}
✅✅ Excellent: Using React Query
import { useQuery } from '@tanstack/react-query';
export function ExcellentComponent({ itemId }: { itemId: number }) {
const { data } = useQuery({
queryKey: ['item', itemId],
queryFn: () => fetch(`/api/items/${itemId}`).then(r => r.json()),
});
return <div>{data?.name}</div>;
}
Notice how the code gets progressively simpler and more robust. React Query handles all edge cases automatically.
Advanced: Cleanup with AbortController
Here's a production-ready pattern that handles complex scenarios:
TypeScript Implementation
import { useEffect, useState, useCallback } from 'react';
interface FetchOptions {
retry?: number;
timeout?: number;
headers?: Record<string, string>;
}
interface UseFetchResult<T> {
data: T | null;
loading: boolean;
error: Error | null;
refetch: () => void;
}
export function useFetch<T>(
url: string,
options?: FetchOptions
): UseFetchResult<T> {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const fetchData = useCallback(async (signal: AbortSignal) => {
try {
setLoading(true);
setError(null);
const controller = AbortController ? new AbortController() : null;
const timeoutId = options?.timeout
? setTimeout(() => controller?.abort(), options.timeout)
: null;
const response = await fetch(url, {
signal: signal,
headers: options?.headers,
});
if (timeoutId) clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const result = await response.json();
setData(result);
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') {
console.log('Fetch cancelled');
return;
}
setError(err instanceof Error ? err : new Error('Unknown error'));
} finally {
setLoading(false);
}
}, [url, options?.headers, options?.timeout]);
useEffect(() => {
const controller = new AbortController();
fetchData(controller.signal);
return () => {
controller.abort();
};
}, [fetchData]);
const refetch = useCallback(() => {
const controller = new AbortController();
fetchData(controller.signal);
}, [fetchData]);
return { data, loading, error, refetch };
}
JavaScript Implementation
import { useEffect, useState, useCallback } from 'react';
export function useFetch(url, options) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const fetchData = useCallback(async (signal) => {
try {
setLoading(true);
setError(null);
const controller = new AbortController();
const timeoutId = options?.timeout
? setTimeout(() => controller.abort(), options.timeout)
: null;
const response = await fetch(url, {
signal: signal,
headers: options?.headers,
});
if (timeoutId) clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const result = await response.json();
setData(result);
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') {
console.log('Fetch cancelled');
return;
}
setError(err instanceof Error ? err : new Error('Unknown error'));
} finally {
setLoading(false);
}
}, [url, options?.headers, options?.timeout]);
useEffect(() => {
const controller = new AbortController();
fetchData(controller.signal);
return () => {
controller.abort();
};
}, [fetchData]);
const refetch = useCallback(() => {
const controller = new AbortController();
fetchData(controller.signal);
}, [fetchData]);
return { data, loading, error, refetch };
}
This hook handles multiple concerns: cancellation via AbortController, timeout handling, HTTP error checking, and refetch capability. This is production-ready and doesn't need useIsMounted at all.
FAQ
Q: Should I always avoid useIsMounted?
A: It depends. For new code, avoid it. Use AbortController or React Query instead. For existing legacy codebases or third-party libraries that don't support cancellation, it's a pragmatic interim solution. The React team's stance is that if you need useIsMounted, your data flow needs rethinking, which is fair advice for new architecture.
Q: What's the performance impact of checking isMounted on every render?
A: Negligible. A simple ref check is extremely fast (just a property access on an object). The real performance cost is elsewhere—in unnecessary re-renders or wasted fetch requests. That's why AbortController is better; it actually prevents the wasted work, not just the state update.
Q: Does AbortController work with all fetch implementations?
A: Not all. It works with native fetch (supported in all modern browsers and Node 15+). If you're using older libraries like axios, they often have their own cancellation tokens. Modern versions of axios support AbortController too. Always check your library's documentation.
Q: Can I use AbortController with axios?
A: Yes! Here's how:
useEffect(() => {
const controller = new AbortController();
axios.get(`/api/users/${userId}`, {
signal: controller.signal,
}).catch(err => {
if (axios.isCancel(err)) {
console.log('Request cancelled');
}
});
return () => {
controller.abort();
};
}, [userId]);
Q: Why does React warn about memory leaks instead of just skipping the update?
A: Because it's a symptom of deeper issues. The warning forces you to think about data flow and cleanup. If React silently skipped updates, you might miss bugs where data is being fetched needlessly in the background. The warning is intentionally annoying to push you toward better patterns.
Q: What about timers and setInterval with useIsMounted?
A: For timers, just use cleanup properly:
useEffect(() => {
const interval = setInterval(() => {
console.log('Tick');
}, 1000);
return () => {
clearInterval(interval);
};
}, []);
This is better than checking isMounted because the interval is actually stopped. You don't need useIsMounted for this pattern at all.
Q: Is useIsMounted compatible with React 19?
A: Yes, it works fine. React 19 still supports refs and effects. However, React 19's improved async handling and the deprecation of certain patterns means you should prefer AbortController or higher-level abstractions like React Query. The direction of React is toward better built-in support for cancellation and async patterns.
Related Articles
- Understanding useEffect: The Dependency Array Explained
- Async Operations in React: Patterns and Best Practices
- Managing Component Lifecycle with Hooks
- React Memory Leaks: Detection and Prevention
- Fetch API and AbortController: Modern Data Loading
Questions? Have you encountered the "Can't perform a React state update on an unmounted component" warning? Share your experience in the comments—did you use useIsMounted, or did you switch to a different pattern? Let us know what works best for your projects!
Google AdSense Placeholder
CONTENT SLOT