React State Update on Unmounted Component エラーの原因と解決方法

React / Next.js

React State Update on Unmounted Component エラーの原因と解決方法

Reactを使用していると、コンソールに「Can't perform a React state update on an unmounted component」というエラーメッセージが表示されることがあります。このエラーは開発時に頻繁に遭遇するもので、多くの初心者開発者を悩ませています。しかし、このエラーの原因と解決方法を理解すれば、安全で効率的なコンポーネント開発ができるようになります。

本記事では、このエラーが発生する理由から、具体的な解決手順、実装例まで、わかりやすく解説していきます。

第1章:エラーの原因を理解する

そもそも「unmounted component」とは何か

Reactコンポーネントにはライフサイクルがあります。コンポーネントが生成され、DOMに挿入される「マウント」という過程と、DOMから削除される「アンマウント」という過程があります。

「unmounted component」とは、既にDOMから削除されたコンポーネントのことです。つまり、もう画面に表示されていないコンポーネントのことを指します。

なぜこのエラーが発生するのか

このエラーが発生するメカニズムは以下の通りです:

  1. 非同期処理(APIリクエストなど)を開始する
  2. その処理が完了する前にコンポーネントがアンマウント(削除)される
  3. 非同期処理が完了し、setState()でstate更新を試みる
  4. 既に削除されたコンポーネントのstateを更新しようとするため、エラーが発生

具体的な例を挙げると、ユーザーが画面遷移してコンポーネントが削除されたのに、その直後にAPI通信の応答が返ってきて、stateを更新しようとするというシナリオです。

このエラーが問題である理由

このエラーは単なる警告ではなく、以下の問題を示唆しています:

  • メモリリーク:不要な処理がメモリに残り続ける可能性がある
  • パフォーマンス低下:蓄積されたメモリリークはアプリケーション全体を遅くする
  • 予期しない動作:削除されたコンポーネントへの参照が残る可能性がある

したがって、このエラーを解決することはコード品質を大きく向上させることにつながります。

第2章:解決手順の詳細解説

ステップ1:useEffectのクリーンアップ関数を使用する

Reactの関数コンポーネントでは、useEffectフックを使用して副作用を管理します。重要なのは、useEffectが「クリーンアップ関数」をサポートしているということです。

クリーンアップ関数とは、コンポーネントがアンマウント(または再レンダリング)される際に実行される関数のことです。このクリーンアップ関数の中で、未完了の非同期処理をキャンセルすることで、エラーを防ぐことができます。

ステップ2:AbortControllerの導入

JavaScript標準のAbortControllerAPIを使用すると、リクエストをキャンセルできます。これは、API呼び出しを途中で停止する機能です。

ステップ3:コンポーネント削除時の処理

アンマウント時に確認フラグを設定し、非同期処理完了後の状態更新をガードすることで、エラーを防止できます。

第3章:実装コード例

解決方法1:AbortControllerを使った実装(推奨)

import React, { useState, useEffect } from 'react';

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    // AbortControllerを作成
    const controller = new AbortController();
    const signal = controller.signal;

    const fetchUser = async () => {
      try {
        setLoading(true);
        const response = await fetch(
          `https://api.example.com/users/${userId}`,
          { signal }
        );
        
        if (!response.ok) {
          throw new Error('ユーザー情報の取得に失敗しました');
        }
        
        const data = await response.json();
        
        // signalがキャンセルされていないかチェック
        if (!signal.aborted) {
          setUser(data);
        }
      } catch (err) {
        // AbortErrorの場合は何もしない
        if (err.name === 'AbortError') {
          console.log('リクエストがキャンセルされました');
          return;
        }
        
        if (!signal.aborted) {
          setError(err.message);
        }
      } finally {
        if (!signal.aborted) {
          setLoading(false);
        }
      }
    };

    fetchUser();

    // クリーンアップ関数:コンポーネントアンマウント時にリクエストをキャンセル
    return () => {
      controller.abort();
    };
  }, [userId]);

  if (loading) return 
読み込み中...
; if (error) return
エラー: {error}
; return (

{user?.name}

メール: {user?.email}

); } export default UserProfile;

解決方法2:フラグを使った実装

import React, { useState, useEffect } from 'react';

function ProductList() {
  const [products, setProducts] = useState([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    // マウント状態を追跡するフラグ
    let isMounted = true;

    const fetchProducts = async () => {
      try {
        const response = await fetch('https://api.example.com/products');
        const data = await response.json();
        
        // コンポーネントがまだマウント状態のときのみ更新
        if (isMounted) {
          setProducts(data);
          setLoading(false);
        }
      } catch (error) {
        if (isMounted) {
          console.error('エラーが発生しました:', error);
          setLoading(false);
        }
      }
    };

    fetchProducts();

    // クリーンアップ関数
    return () => {
      isMounted = false;
    };
  }, []);

  if (loading) return 
読み込み中...
; return (
{products.map((product) => (

{product.name}

価格: ${product.price}

))}
); } export default ProductList;

解決方法3:カスタムフックを使った実装

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

// 再利用可能なカスタムフック
function useFetch(url) {
  const [data, setData] = useRef(null);
  const [loading, setLoading] = useRef(true);
  const [error, setError] = useRef(null);
  const isMountedRef = useRef(true);

  const refetch = useCallback(async () => {
    const controller = new AbortController();
    
    try {
      setLoading.current = true;
      const response = await fetch(url, { signal: controller.signal });
      
      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`);
      }
      
      const result = await response.json();
      
      if (isMountedRef.current) {
        setData.current = result;
        setError.current = null;
      }
    } catch (err) {
      if (err.name !== 'AbortError' && isMountedRef.current) {
        setError.current = err.message;
      }
    } finally {
      if (isMountedRef.current) {
        setLoading.current = false;
      }
    }

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

  useEffect(() => {
    isMountedRef.current = true;
    refetch();

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

  return { 
    data: data.current, 
    loading: loading.current, 
    error: error.current,
    refetch 
  };
}

// 使用例
function App() {
  const { data, loading, error } = useFetch('https://api.example.com/data');

  if (loading) return 
読み込み中...
; if (error) return
エラー: {error}
; return
{JSON.stringify(data)}
; } export default App;

第4章:よくある間違いと対策

間違い1:クリーンアップ関数を使わない

// ❌ 間違った例
function BadComponent() {
  const [data, setData] = useState(null);

  useEffect(() => {
    fetch('/api/data')
      .then(res => res.json())
      .then(data => setData(data)); // クリーンアップなし
  }, []);

  return 
{data?.name}
; }

この例では、API呼び出しが完了する前にコンポーネントがアンマウントされると、エラーが発生します。

間違い2:依存配列を指定しない

// ❌ 間違った例
function BadComponent() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const timer = setInterval(() => {
      setCount(prev => prev + 1);
    }, 1000);
    // 依存配列がないため、毎回レンダリングするたびにタイマーが作成される
  }); // 依存配列がない!

  return 
{count}
; }

間味い3:非同期処理で直接stateを更新

// ❌ 間違った例
useEffect(() => {
  async function fetchData() {
    const res = await fetch('/api/data');
    const data = await res.json();
    setData(data); // アンマウント時の保護がない
  }
  fetchData();
}, []);

// ✅ 正しい例
useEffect(() => {
  let isMounted = true;

  async function fetchData() {
    const res = await fetch('/api/data');
    const data = await res.json();
    if (isMounted) {
      setData(data);
    }
  }
  
  fetchData();

  return () => {
    isMounted = false;
  };
}, []);

第5章:デバッグ方法

開発者ツールでの確認方法

Reactの開発モードでは、このエラーが発生した際に詳細なスタックトレースが表示されます。

1. ブラウザのコンソールを開く(F12キー)

2. 警告メッセージをクリックしてスタックトレースを確認する

3. エラーが発生しているコンポーネントと行番号を特定する

ログを活用したデバッグ

useEffect(() => {
  let isMounted = true;

  const fetchData = async () => {
    console.log('フェッチ開始');
    const response = await fetch('/api/data');
    const data = await response.json();
    
    console.log('isMounted:', isMounted);
    
    if (isMounted) {
      setData(data);
      console.log('state更新完了');
    } else {
      console.log('コンポーネントがアンマウント済みなため、更新をスキップ');
    }
  };

  fetchData();

  return () => {
    console.log('クリーンアップ関数実行');
    isMounted = false;
  };
}, []);

第6章:ベストプラクティス

外部ライブラリの活用

大規模なプロジェクトでは、useQueryやuseSWRなどのデータフェッチライブラリの使用を検討しましょう。これらのライブラリはメモリリーク対策が組み込まれています。

// useQueryを使った例
import { useQuery } from '@tanstack/react-query';

function UserProfile({ userId }) {
  const { data, isLoading, error } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetch(`/api/users/${userId}`).then(res => res.json())
  });

  if (isLoading) return 
読み込み中...
; if (error) return
エラー: {error.message}
; return
{data.name}
; }

TypeScriptでの型安全な実装

import React, { useState, useEffect } from 'react';

interface User {
  id: number;
  name: string;
  email: string;
}

function UserProfile({ userId }: { userId: number }): JSX.Element {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    let isMounted: boolean = true;

    const fetchUser = async (): Promise => {
      try {
        const response = await fetch(`/api/users/${userId}`);
        const data: User = await response.json();
        
        if (isMounted) {
          setUser(data);
          setError(null);
        }
      } catch (err) {
        if (isMounted) {
          setError(err instanceof Error ? err.message : '未知のエラー');
        }
      } finally {
        if (isMounted) {
          setLoading(false);
        }
      }
    };

    fetchUser();

    return () => {
      isMounted = false;
    };
  }, [userId]);

  if (loading) return 
読み込み中...
; if (error) return
エラー: {error}
; if (!user) return
ユーザーが見つかりません
; return (

{user.name}

{user.email}

); } export default UserProfile;

まとめ

「React state update on unmounted component」エラーは、適切な理解と対策により完全に解決できます。本記事の要点をまとめます:

重要なポイント

  • 原因:非同期処理完了後に削除済みコンポーネントのstateを更新しようとすることが原因
  • 解決方法1:AbortControllerを使ってリクエストをキャンセルする(推奨)
  • 解決方法2:isMountedフラグを使ってstateの条件付き更新を行う
  • 解決方法3:useQueryなどのライブラリを使う

実装時の注意点

  • useEffectのクリーンアップ関数を必ず実装する
  • 依存配列を正しく指定する
  • 非同期処理後の状態更新は必ずガードする
  • TypeScriptを使う場合は型安全性を確保する

このエラーはReactを使う限り何度も遭遇するものですが、本記事の内容を理解することで、安全で堅牢なコンポーネント開発ができるようになります。特にAbortControllerを使った方法は最も標準的で推奨される方法であり、プロダクション環境での使用に最適です。

ぜひこれらの方法を実装に取り入れ、エラーのない高品質なReactアプリケーション開発を心がけてください。

タイトルとURLをコピーしました