🦔

Reactの自作コンポーネントで関数を受け取る時の注意点

2024/08/28に公開

自作コンポーネントに関数を渡してuseEffectの中で使う

Fileのアップロードをするためにファイルを選択するコンポーネントを作りました。その中でサードパーティーのライブラリを利用していて、useEffectでそのライブラリを初期化しています(******はライブラリの名前でOSSではないので念の為伏せます)。

import * as React from 'react';
import { useSelector } from 'react-redux';
import { RootState } from '../store';

export type SelectedFile = {
  blob: Blob;
  extension: string;
};

export type SelectedEventHandler = (fileIds: number[], fetchFile: (id: number) => Promise<SelectedFile>) => void;

export type FileSelectorProps = {
  onSelected: SelectedEventHandler;
};

export const FileSelector: React.FC<FileSelectorProps> = ({ onSelected }) => {
  const supportedFileDic = useSelector((state: RootState) => state.app.supportedFileDic);

  React.useEffect(() => {
    window.******.websdk.Initialize();
    window.******.websdk.RegisterEvent('OnScanFinish', async (fileIds: number[]) => {
      if (fileIds != null) {
        onSelected(fileIds, async fileId => {
          const data = await window.******.websdk.GetBlobData(fileId);
          const blob = new Blob([data], { type: 'image/jpeg' });
          return {
            blob: blob,
            extension: supportedFileDic[blob.type],
          };
        });
      }
    });
  }, [onSelected, supportedFileDic]);
...[以下略]

コンポーネントを使う

ダメな例

import * as React from 'react';
import { ViewHeader } from '../components/ViewHeader';
import { FileSelector } from '../components/FileSelector';
import { useAppDispatch } from '../hooks/useAppDispatch';
import { uploadFileItems } from '../actions';
import { useSelector } from 'react-redux';
import { RootState } from '../store';

export const FileItemsView: React.FC = () => {
  const reportId = useSelector((state: RootState) => state.fileItemsView.reportId)!;
  const dispatch = useAppDispatch();

  return (
    <>
      <ViewHeader />
      <div className="space-y-6">
        <FileSelector
          onSelected={(fileIds, fetchFile) => {
            dispatch(uploadFileItems({ reportId: reportId, fileIds, fetchFile }));
          }}
        />
...[以下略]

コンポーネントに関数を渡すとき、このように関数を引数に書くのが一般的?だと思いますが、先のコンポーネントの様にその関数をuseEffectで呼び出していたり、useMemoやuseCallbackでキャッシュしていた場合、このコードは正しくありません。

この関数はレンダリングの度に新しく生成されるので、useEffectは毎回呼び出され、キャッシュは一切効きません。

ではFileSelectorのuseEffectが何度も呼び出されないように、useEffectの依存関係からonSelectedを取り除くとどうなるか?

  React.useEffect(() => {
    window.******.websdk.Initialize();
    window.******.websdk.RegisterEvent('OnScanFinish', async (fileIds: number[]) => {
      ...[省略]
    });
  }, [supportedFileDic]);

こうすると一見期待通りに動きそうですが、今回の場合reportIdがonSelectedに渡される関数にクロージングされるため、reportIdが変わった場合、古い値を参照したまま作り直されないため正しく動きません。

どうするのか?

渡す側の関数をuseCallbackでキャッシュします。

  const onSelectedFiles: SelectedEventHandler = React.useCallback(
    (fileIds, fetchFile) => {
      dispatch(uploadFileItems({ reportId: reportId, fileIds, fetchFile }));
    },
    [dispatch, reportId]
  );

  return (
    <>
      <ViewHeader />
      <div className="space-y-6">
        <FileSelector onSelected={onSelectedFiles} />
...[以下略]

雑感

今回は複数アップロードしたときに、「アップロード中」と表示を変えるためにrenderが走り、サードパーティーの初期化が意図せず再実行され、最初の2つは成功するけど3つ目以降が失敗する、というバグが発生し、気づきましたが、使い方や状況によってはすぐ気づかず、厄介なバグを生んでいた可能性があると思いました。

また、react-hooks/exhaustive-depsのESLintプラグインにも助けられた感じがしますね。

https://github.com/facebook/react/issues/14920

useEffectなどのHookで依存関係の配列に入れるべき変数が入っていなかったときに警告を出すLintです。

これを使ってなかったら、useEffectの依存関係にonSelectedを入れるの忘れていた可能性もあって、その時もかなり厄介なバグを生んだ可能性も高いと思います。

ふと思いましたけど、React公式も含めOSSで公開されてるライブラリのコンポーネントで、関数を受け取ってuseEffectやuseMemoなどで使ってる例ってないのだろうか?これって、使う側からは全くわかりませんよね。ドキュメントに書いてあったとしもて気づくかどうか・・・

依存関係には注意していましたが気を抜くとはまりますね。恥ずかしながら依存関係に関数を渡してバグを発生させるのは2度目で、備忘録のためにも記事書きました。

参考サイト

react-hooks/exhaustive-depsで関数を扱う時の話題は下記に詳しいです。

https://stackoverflow.com/questions/58866796/understanding-the-react-hooks-exhaustive-deps-lint-rule

ちょっと古いけど公式の解説。

https://legacy.reactjs.org/docs/hooks-faq.html#is-it-safe-to-omit-functions-from-the-list-of-dependencies

Discussion