銀の弾丸

プログラミングに関して、いろいろ書き残していければと思っております。

React:非同期の副作用フック(useEffect)で正しくクリーンアップする

f:id:takamints:20200319064019j:plain

Reactの関数コンポーネントで非同期の副作用フックを正しく記述する方法を書いています。

非同期処理でコンポーネントを更新する場合には、副作用フックを使用する必要があり、この副作用フックからはクリーンアップ関数を返却しておく必要があります。 REST APIなどの非同期処理によってデータを取得し画面に反映する場合などには、非同期の結果待ちの間に更新先の画面がなくなっている(DOMがアンマウントされる)かも知れないためで、それに対処しなければなりません。

副作用フックに対して、更新先の画面がなくなったことを伝えるための関数がクリーンアップ関数で、このクリーンアップ関数は副作用フックが返却する関数オブジェクトです。 (なんだか話が前後しているようでややこしいですが、この動作機序を理解するには、JavaScriptの非同期処理の動作、関数オブジェクト、そしてクロージャについての知識が必要かと思います)

目次


副作用フックについて

副作用フックは、関数コンポーネントの中で利用できるフックです。 DOMがマウントされて画面が更新されたあとに呼ばれる関数を登録します。

この関数内で、何らかの条件によって、コンポーネントのステートを更新すれば、再び画面が更新されるという仕組みです。

画面が更新されると、また同じ副作用フックを登録することになりますから、無限に更新を繰り返すことは避ける必要がありますね。

非同期動作する副作用フック

この関数内で、例えばREST APIを呼び出して、非同期でデータを取得してステートを更新すると、再び画面を更新することになるのですが、既にユーザー操作によって別の画面に遷移していると、更新すべきコンポーネントがなくなっているかもしれません。

アンマウントされたDOMに対してステートを更新すると「メモリリークしてるかも!」という警告が表示されます。

つまり、DOMがアンマウントされているときにはステート更新を行わないようにするべきなのです。

クリーンアップ関数でDOMのアンマウントを知る

そこでDOMがアンマウントされたことを知るために、副作用フックの「クリーンアップ関数」を使用します。 クリーンアップ関数は副作用フックが返す関数です。この関数は副作用フックが呼び出されたあと、DOMがアンマウントされた時に呼び出されます。

実行順序をざっくり書けば、

  1. 画面がマウントされ、
  2. 副作用フックが呼ばれ、
  3. 副作用フックがクリーンアップ関数を返し、
  4. DOMがアンマウントされたときに呼び出される

ということになります。

つまり、非同期動作を待っている(await中の)副作用フックに対して、既に返したクリーンアップ関数にDOMのアンマウントが通知されるということです。 非同期動作が完結したとき、既にクリーンアップ関数が呼ばれていたならDOMがアンマウントされているのでステート更新を行わなければよいのです。

正しくない非同期関数コンポーネント

副作用フックを使わない(動くけど正しくない)

一方、非同期でのステート更新は副作用フックでなくても一応動きます。 以下は副作用フックを使っていない不完全な非同期関数コンポーネントなんですが、一見正常動作は可能です。 しかし、既に上に書いたように、非同期処理が完了するまでの間にユーザー操作によって別の画面に遷移したとすると「メモリリークの可能性がある」という警告が出ます。

import React from "react";
export default function AsyncComponent() {

    //非同期で取得するデータ
    const [result, setResult] = React.useState(null);

    //非同期無名関数の即時呼び出し
    (async ()=> {

        //非同期でデータを取得し、ステート更新
        const result = await getAsyncData();//架空の関数
        setResult(result);

    })();

    return (
        <div>
            { result ? <p> { result } </p> : <p> loading... </p> }
        </div>
    );
};

関数コンポーネント自体をAsyncにしちゃう(動かない)

どこにも書いていなかったようなので、念の為に試してみたのですが、 関数コンポーネント自体をAsync関数にした場合、ビルド時エラーは出ませんが、まったく動きませんでした。

import React from "react";
export default async function AsyncComponent() {
    const [result, setResult] = React.useState(null);
    setResult(await getDataAsync());
    return (
        <div style={{textAlign: "left"}}>
            {
                markdown ? 
                    <article dangerouslySetInnerHTML={{__html: markdown}}/>:
                    <p>loading...</p>
            }
        </div>
    );
};

副作用フックをAsyncにしちゃう(できない)

Async関数は必ずPromise オブジェクトが返します。 しかし先に書いたように、副作用フックはクリーンアップ関数を返さなくてはなりません。 だから副作用フックの関数自体をAsync関数にはできません。

正しい非同期副作用フックとクリーンアップ関数

コンポーネントがアンマウントされたかどうかを知るために副作用フックを使います。 副作用フックの戻り値は「クリーンアップ関数」と呼ばれる関数オブジェクトで、後にコンポーネントがアンマウントされたとき呼び出してもらえます。

以下のコードで示すように( React.useEffect の部分)。

import React from "react";

//非同期関数コンポーネント
export default function AsyncComponent() {

    //非同期で取得するデータ
    const [result, setResult] = React.useState(null);

    // 副作用フック
    React.useEffect(()=>{
        let unmounted = false;

        //非同期無名関数の即時呼び出し
        (async()=>{

            //非同期でデータを取得
            const result = await getAsyncData();//架空の関数

            //アンマウントされていなければステートを更新
            if(!unmounted) {
                setResult(result);
            };

        })();

        //クリーンアップ関数を返す
        return ()=>{ unmounted = true; };
    });

    return (
        <div>
            { result ? <p> { result } </p> : <p> loading... </p> }
        </div>
    );
};

副作用フックの最初で unmounted という変数を初期値falseで宣言しています。 これは非同期処理中にコンポーネントがアンマウントされたかどうかを保持します。 最初の時点ではアンマウントされていません。

次に、関数の内部で無名のAsync関数を即時呼び出ししています。 この関数の中で非同期にデータを取得したあと、アンマウントされていなければステートを更新しています。

最初に宣言された unmounted は、この副作用フックが返す「クリーンアップ関数」で true に更新されています。 つまり非同期処理が完了する前にコンポーネントがアンマウントされると、ステートは更新されませんから、メモリリークの心配はありません。