AdSense Leaderboard

Google AdSense Placeholder

HEADER SLOT

Custom useIsMounted Hook: Prevent Memory Leaks (2026)

Last updated:
Custom useIsMounted Hook: Prevent Memory Leaks (2026)

Master the useIsMounted hook to safely handle async operations in React. Learn why it matters, how to build it correctly, and see patterns that prevent memory leak warnings in production.

# 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:

typescript
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

  1. Understanding the Memory Leak Warning
  2. Building a Basic useIsMounted Hook
  3. The Problem with useIsMounted
  4. Modern Alternative Patterns
  5. When useIsMounted Still Makes Sense
  6. Practical Examples: From Bad to Good
  7. Advanced: Cleanup with AbortController
  8. 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:

  1. Component mounts and renders
  2. Component starts an async operation (like fetch)
  3. Component unmounts before the async operation completes
  4. The async operation completes and tries to update state
  5. React detects that state is being set on an unmounted component

Here's a real-world example that triggers the warning:

# TypeScript Example (Problematic)

typescript
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

typescript
import { useEffect, useRef } from 'react';

export function useIsMounted() {
  const isMountedRef = useRef(true);

  useEffect(() => {
    return () => {
      isMountedRef.current = false;
    };
  }, []);

  return isMountedRef.current;
}

# JavaScript Version

javascript
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

typescript
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

javascript
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 isMounted and 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

typescript
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

javascript
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:

typescript
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

typescript
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:

typescript
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

typescript
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

typescript
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

typescript
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

typescript
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

typescript
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

javascript
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:

typescript
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:

typescript
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.



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!

Sponsored Content

Google AdSense Placeholder

CONTENT SLOT

Sponsored

Google AdSense Placeholder

FOOTER SLOT