propsに無名関数コンポーネントを渡すとき、メモ化するとESLintのrules-of-hooksエラーが出ない
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-hooksのrules-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ルールです。
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のエラーが消えるのか
続いてどうしてメモ化するとESLintのエラーが消えるのかについて解説します。
これについてはeslint-plugin-react-hooks
のソースコードを読むことで突き止めることができました。
(一応補足しておきますが、私のESLintルールの理解度は自分で「変数名をアンダーバーから始めない」というシンプルなルールを1つ作ったことがある程度です。その程度の知識でもソースコードを読めば雰囲気でやっていることはわかるという趣旨で説明するため、詳細な説明は割愛させていただきます。ただ、ESLintのルール開発のドキュメントがいつの間にかめちゃめちゃ読みやすくなっているので、興味ある方は読んでみるといいかもしれません)
まず、本エラーをまさにレポートしている処理はGrepによって以下の箇所と特定できます。
ここで条件の一つになっているisSomewhereInsideComponentOrHook
は以下の行にてisInsideComponentOrHook
という関数の結果を得ています(ちなみにもうひとつの条件であるisUseIdentifier
はこちらの記事などで解説されているuse関数のことです。use関数は通常のフックのルールが適用されないので省かれています)。
最後にisInsideComponentOrHook
関数を読むことで、メモ化すればいいことが推察できます。
L98にisMemoCallback
関数があり、メモ化するとこの関数の戻り値がTrueになるので無名関数がReactコンポーネントとみなされフックのルールで怒られなくなります。ちなみによく読むとmemo(...)
ではなくてReact.memo(...)
でも大丈夫なように書かれていますね。芸が細かい。
補足
無名関数でコンポーネントを渡したいときの事例
本記事のように無名関数でコンポーネントを渡したいときの事例について補足説明します。
例でいうところのPassComponentAsProps
が基盤Formコンポーネントで、内部でreact-hook-form
のuseForm
を呼んでその返り値を引数で受け取ったコンポーネントに渡す形で、各FormコンポーネントでuseFormの返り値を受け取って実際のフォーム処理を書けるという感じで設計されたソースを見たことがあって、そのときにESLintルールで怒られるので対策を調べた運びとなります。
メモ化を縛る方法
以下のようにchildrenの型をメモ化したコンポーネントの型にすることで、(一応)型レベルでメモ化を強制できます。
type Props = {
Body: MemoExoticComponent<((props: {flag: boolean}) => JSX.Element)>
}
そもそもメモ化をESLintのために強制していいのか
ここまで進めて疑問に思うのは、ESLintのルールを通すためにメモ化を強制していいのか、ということです。
memo
関数の実行のコストはゼロではないと思う一方で、基本的にはメモ化しておいたほうが不要な再レンダリングが防がれるのでよいのでは、とも思います(無名関数でchildrenに渡したときも再レンダリングが防がれるかどうか自信はないので追って検証するつもりです)。私の考えとしては基本的にメモ化強制でいいと思うのですが、型レベルの強制含め適用するときはチームの運用ルールやコンポーネントの利用目的と照らし合わせるのがよいと思います。
Discussion
あとでちゃんと調べますが、こういうときは memo ではなく useCallback を使うのが普通です。ESLint のエラーを消すためのワークアラウンドに過ぎないはずです(実際、deps で警告されている内容に即した対応でない)
コメントありがとうございます。ESLintのルールを通すためにメモ化を強制していいのかと記事を書いているときも疑問には思っていましたが、やはりmemoをLintエラーを消すために使うのはおかしいでしょうか。
ただ、以下のようにuseCallbackを使ってもLintエラーは消えませんでした。
少し面倒でも以下のように変数としてコンポーネントを定義し直すのがいいんですかね(※面倒、というのは
FC<{flag: boolean}>
の部分で型を当てないといけないところです。無名関数をchildrenに突っ込む方針だとそこの型は勝手に当たります)。すみません全然コード読んでませんでした。この場合はそもそも useState を使ってるのがマズいので外部にコンポーネントを抜き出さなければいけません。memo を使ったからセーフでは全然ないです。lint エラーのメッセージにちゃんとなんでダメか書いてあると思いますのでそちら参照ください
すみません、自分のほうも間違えていました。サンプルコードでは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を呼ぶことは問題ないという認識です。
React function component
という認識が間違いかつ、
といった感じでしょうか。
はい、違います。 具体的にいうと フックを呼び出すのはトップレベルのみ といったときの React function component はコンポーネント内部で定義したコンポーネントは含みません。
(手元でぜんぜん試してない状態なのでアレなんですが)例えば
< PassComponentAsProps showsBody={showsBody}/> <input type="checkbox" checked={showsBody} onChange={showsBody を切り替える}/>
みたいに body を表示するかどうかを切り替えてみると実行時エラーが起きるはずです(memo で囲んでもエラーになるはず)hook の内部動作の話をしないとなんでダメなのか説明できないので、詳しい話はここでは書きません(参考文献を今探してます)
というか、それ以前にコンポーネントの中でコンポーネントを定義するのは bad practice です。render prop パターンのようにコンポーネントを返す関数を定義するのはセーフですが、コンポーネントを定義するのはダメです
幾らか検証用にコード書いてたんですが実行時エラー出ないですね…
↑実行時エラーが出るパターンも実際にあるにはあるので、やはり変なワークアラウンドを入れるのは罠になりかねないので推奨されないとは思いますが、今回のワークアラウンドが実際にどう罠になるかまでは分からない、現段階ではこうだと思いました
検証までしていただいてありがとうございます!
なるほど、これBad Practiceなんですね(確かに少なくとも王道のやり方ではないですね。ハックですよね)。
こちらについては参考文献を読んでみます。
本記事の無名関数でコンポーネントを定義して渡す考え方は、以下のリポジトリのコードの設計を真似て考えたのですが、「コンポーネントの中でコンポーネントを定義するのは bad practice」なのであればこちらの実装もNGでしょうか?
添付のQiita記事では実行時エラーになってしまうのはprint関数がただの関数として呼び出されているからで、本記事の例では無名関数はレンダリング時に呼び出されている点(
React.createElement
から呼び出されている)が異なるかなと思いました。React.createElement
実行時に呼び出された関数はInvalid Hook Callを呼び起こさないのでしょうか(あくまで仮説の域を出ない&支離滅裂なこと言っているかもしれないです。それかシンプルにprint()はvoidなのに対してJSX.Elementを返す関数はComponentとして扱われるというだけの話かもしれない)ただ、いずれにせよ以下の見解はそのとおりだと思いました。できるだけ使わない方針で今後コードを書いていこうと思います。
呼び出されるフックの数が shows が false のとき0個、true のとき1個だったのがよくなかったみたいで false のとき1個、true のとき2個にしたらエラーでました。
でもこれを memo で囲むとエラーが消えますね。でも、これはそもそも
{body()}
ではなく<Body/>
と書く必要があり、こうなるとコンポーネントとして認識されてフックの記憶領域が確保されるからですねどんどん本題からズレていくんですがフックの呼び出し個数が 0 ⇆ N で切り替わるぶんには実行時エラーが出ないみたいです。知らなかった…どっちにしろ react からすれば未定義動作のはずで動作が保証されてないので使ってはいけないと思いますが
このパターン自体は render prop と呼ばれています。コンポーネントを渡しているわけではないはずです
コンポーネントを渡している場合はこちらの書き方しかできません。
前者の場合、children のなかでフックを呼び出すと、それは親コンポーネントのステートになります。
後者の場合、Body のなかでフックを呼び出すとそれは Body のステートになります。
基本的にコンポーネントを渡す箇所ではフックを使っても問題ないですが、関数として渡す箇所は内部でどっちの方法(
Body()
か<Body/>
か)を使っているかわからない…というか十中八九Body()
なのでフックを呼び出してはいけない、結論はこうだと思います。(書きながら僕の理解不足も結構あらわになったのでまだ間違えてる可能性はあります)
なるほど、丁寧に説明いただいてありがとうございます。自分の中でも整理できてきました。
memoで囲むとComponentとして認識されてフックの記憶領域が確保されるからこそ、そもそも本記事の主題だったESLintのルールも警告しなくなるように設計されているということでしょうね。
一方、render propを使っている場合は関数なのでフックを内部で呼び出さないほうがいい(0→Nのときはセーフだが1→2以上のケースでエラーとなる※自分の方でも動作確認しました)。
私が参考にしたリポジトリはrender propを使っている→関数内でフックを呼び出すことを想定した設計ではない、と思われますね。
かつ、render propのときに内部でフックを使うとESLintはしっかり警告してくれるわけなので、筋が通ってますね。
render propとComponentの違いを自分が曖昧に把握していたのと、0→Nのときがセーフだったことが問題をややこしくしていたようですね。
本件、render propの中でフックを使っている実装を見かけて、それがESLintで警告されているからどうにかする方法を考えたのがきっかけの記事なのですが、render propの中でフックを使っている時点でそれはコンポーネントとして切り出したほうがいいんでしょうね・・・
(render propに渡ってきている引数をコンポーネントに切り出したときもPropsの型定義として用意しないといけないのが少々面倒ですがそれをやるしかなさそう。またはrender propを必要としているコンポーネントの外側でフックを呼ぶことでも回避できそう)