🐡

propsに無名関数コンポーネントを渡すとき、メモ化するとESLintのrules-of-hooksエラーが出ない

2023/01/09に公開
13

TL;DR

Reactのコンポーネントを共通化するときに、PropsでComponentを渡したいときがたまにあります。
以下の例はPassComponentAsPropsコンポーネントのBodyにコンポーネントを渡している例です。

      <PassComponentAsProps
        Body={({flag}) => {
          const [state, setState] = useState(false);
          return (
            <div>
              <p>props.flag: {flag}</p>
              <p>local state: {state}</p>
            </div>
          )
        }}
      ></PassComponentAsProps>

しかし上記のように関数コンポーネントをその場で作って無名関数として渡すような書き方をしていると、コード中3行目のuseState(false)のところでeslint-plugin-react-hooksrules-of-hooksに引っかかり、エラーまたは警告となります(無名関数ではなく変数として別で定義しているとルール違反にはなりません)。

これを回避しつつ実装の手間をさほど掛けない方法として、メモ化する方法を発見しました。

      <PassComponentAsProps
        Body={memo(({flag}) => {
          const [state, setState] = useState(false);
          return (
            <div>
              <p>props.flag: {flag}</p>
              <p>local state: {state}</p>
            </div>
          )
        })}
      ></PassComponentAsProps>

なので、もしこの問題にちょうど困っていて回避する方法さえ知れればそれで良い方はここで読み終えていただいて大丈夫です。

本記事の残りの部分では

  • 実際にESLintのエラーが消えるのか検証した結果(全体的なサンプルコード添付)
  • どうしてメモ化するとESLintのエラーが消えるのか(eslint-plugin-react-hooksのソースコードを読みました)
  • 補足

を解説していきます。

検証したコード

検証に使ったソースコードを示します。

まずはESLintルールです。

.eslintrc.js
module.exports = {
  env: {
    browser: true,
    es2021: true
  },
  extends: [
    'plugin:react/recommended',
    "plugin:react-hooks/recommended"
  ],
  parser: '@typescript-eslint/parser',
  parserOptions: {
    ecmaFeatures: {
      jsx: true
    },
    ecmaVersion: 12,
    sourceType: 'module'
  },
  settings: {
    react: {
      version: 'detect',
    },
  },
  plugins: [
    'react',
    '@typescript-eslint'
  ],
  rules: {
    "react-hooks/rules-of-hooks": "error",
    'react/prop-types': 'off',
    'react/react-in-jsx-scope': 'off',
    'react/display-name': 'off',
  }
}

検証のため簡単な設定のみにしていますが、"react-hooks/rules-of-hooks": "error",にしているところだけ確認頂ければと思います。

続いてPropsでComponentを受け取るコンポーネントです。

import {useState} from "react";

type Props = {
  Body: ((props: {flag: boolean}) => JSX.Element) // Body: FC<{flag: boolean}>でもよい
}

export const PassComponentAsProps = ({ Body }: Props) => {
  const [state, setState] = useState(false);
  return (
    <div>
      <Body flag={state}></Body>
    </div>
  )
};

props.Bodyとしてコンポーネントを受け取って表示しています。単に表示するだけだと利用側で名前付けでコンポーネントを作って渡すのと手間があまり変わらなくなるので、内部的にステートを作ってそのステートを渡すようにしてみました。こうすると利用側は名前付けてコンポーネントを作るにはPropsの型を定義する必要が出てきて多少面倒になってしまいますので、本記事の主題のように無名関数を使うメリットが生まれます。

最後にESLintのエラーが出るかどうか検証するためのコンポーネントです。

import {PassComponentAsProps} from "~/components/domain/trial/PassComponentAsProps";
import {memo, useState} from "react";

type Props = {}

export const ESLintErrorTest = (props: Props) => {
  return (
    <>
      <PassComponentAsProps
        Body={({flag}) => {
          const [state, setState] = useState(false);
          return (
            <div>
              <p>props.flag: {flag}</p>
              <p>local state: {state}</p>
            </div>
          )
        }}
      ></PassComponentAsProps>
      <PassComponentAsProps
        Body={memo(({flag}) => {
          const [state, setState] = useState(false);
          return (
            <div>
              <p>props.flag: {flag}</p>
              <p>local state: {state}</p>
            </div>
          )
        })}
      ></PassComponentAsProps>
    </>
  )
};

このESLintErrorTestコンポーネントをIDE上で実装してみると以下画像のように、メモ化していないコンポーネントを渡しているところではエラーを示す赤線が引かれていることがわかります(割愛しますがエラー内容はrules-of-hooksでした)。

eslint error

どうしてメモ化するとESLintのエラーが消えるのか

続いてどうしてメモ化するとESLintのエラーが消えるのかについて解説します。
これについてはeslint-plugin-react-hooksのソースコードを読むことで突き止めることができました。
(一応補足しておきますが、私のESLintルールの理解度は自分で「変数名をアンダーバーから始めない」というシンプルなルールを1つ作ったことがある程度です。その程度の知識でもソースコードを読めば雰囲気でやっていることはわかるという趣旨で説明するため、詳細な説明は割愛させていただきます。ただ、ESLintのルール開発のドキュメントがいつの間にかめちゃめちゃ読みやすくなっているので、興味ある方は読んでみるといいかもしれません)

まず、本エラーをまさにレポートしている処理はGrepによって以下の箇所と特定できます。

https://github.com/facebook/react/blob/de7d1c90718ea8f4844a2219991f7115ef2bd2c5/packages/eslint-plugin-react-hooks/src/RulesOfHooks.js#L544-L550

ここで条件の一つになっているisSomewhereInsideComponentOrHookは以下の行にてisInsideComponentOrHookという関数の結果を得ています(ちなみにもうひとつの条件であるisUseIdentifierこちらの記事などで解説されているuse関数のことです。use関数は通常のフックのルールが適用されないので省かれています)。

https://github.com/facebook/react/blob/de7d1c90718ea8f4844a2219991f7115ef2bd2c5/packages/eslint-plugin-react-hooks/src/RulesOfHooks.js#L386

最後にisInsideComponentOrHook関数を読むことで、メモ化すればいいことが推察できます。

https://github.com/facebook/react/blob/de7d1c90718ea8f4844a2219991f7115ef2bd2c5/packages/eslint-plugin-react-hooks/src/RulesOfHooks.js#L90-L104

L98にisMemoCallback関数があり、メモ化するとこの関数の戻り値がTrueになるので無名関数がReactコンポーネントとみなされフックのルールで怒られなくなります。ちなみによく読むとmemo(...)ではなくてReact.memo(...)でも大丈夫なように書かれていますね。芸が細かい。

補足

無名関数でコンポーネントを渡したいときの事例

本記事のように無名関数でコンポーネントを渡したいときの事例について補足説明します。
例でいうところのPassComponentAsPropsが基盤Formコンポーネントで、内部でreact-hook-formuseFormを呼んでその返り値を引数で受け取ったコンポーネントに渡す形で、各FormコンポーネントでuseFormの返り値を受け取って実際のフォーム処理を書けるという感じで設計されたソースを見たことがあって、そのときにESLintルールで怒られるので対策を調べた運びとなります。

https://github.com/alan2207/bulletproof-react/blob/master/src/components/Form/Form.tsx

メモ化を縛る方法

以下のようにchildrenの型をメモ化したコンポーネントの型にすることで、(一応)型レベルでメモ化を強制できます。

type Props = {
  Body: MemoExoticComponent<((props: {flag: boolean}) => JSX.Element)>
}

そもそもメモ化をESLintのために強制していいのか

ここまで進めて疑問に思うのは、ESLintのルールを通すためにメモ化を強制していいのか、ということです。
memo関数の実行のコストはゼロではないと思う一方で、基本的にはメモ化しておいたほうが不要な再レンダリングが防がれるのでよいのでは、とも思います(無名関数でchildrenに渡したときも再レンダリングが防がれるかどうか自信はないので追って検証するつもりです)。私の考えとしては基本的にメモ化強制でいいと思うのですが、型レベルの強制含め適用するときはチームの運用ルールやコンポーネントの利用目的と照らし合わせるのがよいと思います。

Discussion

クロパンダクロパンダ

あとでちゃんと調べますが、こういうときは memo ではなく useCallback を使うのが普通です。ESLint のエラーを消すためのワークアラウンドに過ぎないはずです(実際、deps で警告されている内容に即した対応でない)

meijinmeijin

コメントありがとうございます。ESLintのルールを通すためにメモ化を強制していいのかと記事を書いているときも疑問には思っていましたが、やはりmemoをLintエラーを消すために使うのはおかしいでしょうか。

ただ、以下のようにuseCallbackを使ってもLintエラーは消えませんでした。

      <PassComponentAsProps>
        {
          useCallback(({flag}) => {
            const [state, setState] = useState(false);
            return (
              <div>
                <p>props.flag: {flag}</p>
                <p>local state: {state}</p>
              </div>
            )
          }, [])
        }
      </PassComponentAsProps>

少し面倒でも以下のように変数としてコンポーネントを定義し直すのがいいんですかね(※面倒、というのはFC<{flag: boolean}>の部分で型を当てないといけないところです。無名関数をchildrenに突っ込む方針だとそこの型は勝手に当たります)。

  const SampleComponent: FC<{flag: boolean}> = ({flag}) => {
    const [state, setState] = useState(false);
    return (
      <div>
        <p>props.flag: {flag}</p>
        <p>local state: {state}</p>
      </div>
    )
  }
  return (
    <>
      <PassComponentAsProps>
        {SampleComponent}
      </PassComponentAsProps>
// ...以下略...
クロパンダクロパンダ

すみません全然コード読んでませんでした。この場合はそもそも useState を使ってるのがマズいので外部にコンポーネントを抜き出さなければいけません。memo を使ったからセーフでは全然ないです。lint エラーのメッセージにちゃんとなんでダメか書いてあると思いますのでそちら参照ください

meijinmeijin

すみません、自分のほうも間違えていました。サンプルコードではchildrenで無名関数を受け取る形で実装していましたが、props.Bodyに渡す形式に修正しました。childrenを通して渡す方針だと動作しませんでした。

続いて本題ですがReact Hook "useState" cannot be called inside a callback. React Hooks must be called in a React function component or a custom React Hook function.がLintエラーメッセージなのですが、私の思うReact function componentの定義が間違っていますかね?
自分としてはJSX.Elementを返す関数はたとえ無名関数であってもReact function componentだと思っているので、以下のprops.Bodyに渡している関数はReact function componentだと考えています(し、このコードはESLintの警告を受けるにも拘わらず動作します)。
ですので、内部でuseStateなどのHookを呼ぶことは問題ないという認識です。

      <PassComponentAsProps
        Body={({flag}) => {
          const [state, setState] = useState(false);
          return (
            <div>
              <p>props.flag: {flag}</p>
              <p>local state: {state}</p>
            </div>
          )
        }}
      ></PassComponentAsProps>
  • JSX.Elementを返す関数はたとえ無名関数であってもReact function componentという認識が間違い

かつ、

  • 本記事のサンプルコードが動作しているのは何らかの偶然であって、本来やるべき設計ではない

といった感じでしょうか。

クロパンダクロパンダ

JSX.Elementを返す関数はたとえ無名関数であってもReact function componentという認識が間違い

はい、違います。 具体的にいうと フックを呼び出すのはトップレベルのみ といったときの React function component はコンポーネント内部で定義したコンポーネントは含みません。

(手元でぜんぜん試してない状態なのでアレなんですが)例えば < PassComponentAsProps showsBody={showsBody}/> <input type="checkbox" checked={showsBody} onChange={showsBody を切り替える}/> みたいに body を表示するかどうかを切り替えてみると実行時エラーが起きるはずです(memo で囲んでもエラーになるはず)

クロパンダクロパンダ

hook の内部動作の話をしないとなんでダメなのか説明できないので、詳しい話はここでは書きません(参考文献を今探してます)

というか、それ以前にコンポーネントの中でコンポーネントを定義するのは bad practice です。render prop パターンのようにコンポーネントを返す関数を定義するのはセーフですが、コンポーネントを定義するのはダメです

https://blog.isquaredsoftware.com/2020/05/blogged-answers-a-mostly-complete-guide-to-react-rendering-behavior/#component-types-and-reconciliation

クロパンダクロパンダ

https://codesandbox.io/s/agitated-water-nw7m30?file=/src/App.tsx
幾らか検証用にコード書いてたんですが実行時エラー出ないですね…

https://qiita.com/tatsumin0206/items/4e1076e2deedf20a9485#ルール-フックはトップレベルでしか呼び出せない
↑実行時エラーが出るパターンも実際にあるにはあるので、やはり変なワークアラウンドを入れるのは罠になりかねないので推奨されないとは思いますが、今回のワークアラウンドが実際にどう罠になるかまでは分からない、現段階ではこうだと思いました

meijinmeijin

検証までしていただいてありがとうございます!

コンポーネントの中でコンポーネントを定義するのは bad practice です。render prop パターンのようにコンポーネントを返す関数を定義するのはセーフですが、コンポーネントを定義するのはダメです

なるほど、これBad Practiceなんですね(確かに少なくとも王道のやり方ではないですね。ハックですよね)。
こちらについては参考文献を読んでみます。

本記事の無名関数でコンポーネントを定義して渡す考え方は、以下のリポジトリのコードの設計を真似て考えたのですが、「コンポーネントの中でコンポーネントを定義するのは bad practice」なのであればこちらの実装もNGでしょうか?
https://github.com/alan2207/bulletproof-react/blob/master/src/features/comments/components/CreateComment.tsx#L53


実行時エラー出ないですね…

添付のQiita記事では実行時エラーになってしまうのはprint関数がただの関数として呼び出されているからで、本記事の例では無名関数はレンダリング時に呼び出されている点(React.createElementから呼び出されている)が異なるかなと思いました。

  return <div>{body()}</div>;

React.createElement実行時に呼び出された関数はInvalid Hook Callを呼び起こさないのでしょうか(あくまで仮説の域を出ない&支離滅裂なこと言っているかもしれないです。それかシンプルにprint()はvoidなのに対してJSX.Elementを返す関数はComponentとして扱われるというだけの話かもしれない)


ただ、いずれにせよ以下の見解はそのとおりだと思いました。できるだけ使わない方針で今後コードを書いていこうと思います。

やはり変なワークアラウンドを入れるのは罠になりかねないので推奨されないとは思いますが、今回のワークアラウンドが実際にどう罠になるかまでは分からない

クロパンダクロパンダ

https://codesandbox.io/s/summer-smoke-t765rf?file=/src/App.tsx
呼び出されるフックの数が shows が false のとき0個、true のとき1個だったのがよくなかったみたいで false のとき1個、true のとき2個にしたらエラーでました。

でもこれを memo で囲むとエラーが消えますね。でも、これはそもそも {body()} ではなく <Body/>と書く必要があり、こうなるとコンポーネントとして認識されてフックの記憶領域が確保されるからですね

クロパンダクロパンダ

本記事の無名関数でコンポーネントを定義して渡す考え方は、以下のリポジトリのコードの設計を真似て考えたのですが、「コンポーネントの中でコンポーネントを定義するのは bad practice」なのであればこちらの実装もNGでしょうか?

このパターン自体は render prop と呼ばれています。コンポーネントを渡しているわけではないはずです

children(); // render prop はコレができる

コンポーネントを渡している場合はこちらの書き方しかできません。

<Body />; // Body() とはできない

前者の場合、children のなかでフックを呼び出すと、それは親コンポーネントのステートになります。
後者の場合、Body のなかでフックを呼び出すとそれは Body のステートになります。

基本的にコンポーネントを渡す箇所ではフックを使っても問題ないですが、関数として渡す箇所は内部でどっちの方法(Body()<Body/> か)を使っているかわからない…というか十中八九 Body() なのでフックを呼び出してはいけない、結論はこうだと思います。

(書きながら僕の理解不足も結構あらわになったのでまだ間違えてる可能性はあります)

meijinmeijin

なるほど、丁寧に説明いただいてありがとうございます。自分の中でも整理できてきました。

memo で囲むとエラーが消えますね。でも、これはそもそも {body()} ではなく <Body/>と書く必要があり、こうなるとコンポーネントとして認識されてフックの記憶領域が確保される

memoで囲むとComponentとして認識されてフックの記憶領域が確保されるからこそ、そもそも本記事の主題だったESLintのルールも警告しなくなるように設計されているということでしょうね。

このパターン自体は render prop と呼ばれています。コンポーネントを渡しているわけではないはずです

一方、render propを使っている場合は関数なのでフックを内部で呼び出さないほうがいい(0→Nのときはセーフだが1→2以上のケースでエラーとなる※自分の方でも動作確認しました)。
私が参考にしたリポジトリはrender propを使っている→関数内でフックを呼び出すことを想定した設計ではない、と思われますね。
かつ、render propのときに内部でフックを使うとESLintはしっかり警告してくれるわけなので、筋が通ってますね。


render propとComponentの違いを自分が曖昧に把握していたのと、0→Nのときがセーフだったことが問題をややこしくしていたようですね。

meijinmeijin

本件、render propの中でフックを使っている実装を見かけて、それがESLintで警告されているからどうにかする方法を考えたのがきっかけの記事なのですが、render propの中でフックを使っている時点でそれはコンポーネントとして切り出したほうがいいんでしょうね・・・

(render propに渡ってきている引数をコンポーネントに切り出したときもPropsの型定義として用意しないといけないのが少々面倒ですがそれをやるしかなさそう。またはrender propを必要としているコンポーネントの外側でフックを呼ぶことでも回避できそう)