🙆

render hooks パターンの注意点と対策

2023/02/15に公開
2

以下の記事で紹介されている「render hooks パターン」には注意点があるため、簡単な例と共に注意点についてまとめました。

https://engineering.linecorp.com/ja/blog/line-securities-frontend-3/

要約

Custom Hook から Component を返却する render hooks パターンの場合、返却された Component がアンマウントされてしまうことによりバグが発生する恐れがあるので注意が必要です。
Custom Hook から Component を返さずに React element を返す実装にすれば、 アンマウントを回避できます。

Component を返却する render hooks パターンの実装例と問題点

問題の視覚的確認

以下の codesandbox の例の「Open Modal」ボタンを押下してみてください。
Modal コンポーネントが現れるのですが、一定間隔でチカチカしています。

実装例

実装は以下の通りです。
https://github.com/fneco/render-hooks-pattern-with-chakra-ui/blob/return-component/src/components/ModalButton.tsx
useModalで render hooks パターンを使っています。 useModalは、モーダルを開くかどうかの boolean の state (isOpen)と、その state を変更する関数 (onClose) を Modalコンポーネントに取り込んだ、ModalFromHookコンポーネントを作成し、返却しています。
useModalを利用するコンポーネントは、モーダルが開いているかどうかの状態(isOpen)を意識しなくてよく、 モーダルをオープンしたい時にonOpenを呼べばモーダルをオープンできます。状態が隠蔽されています。

問題の内容

しかし、上記のようにmemo化なしに Custom Hook (useModal)内でコンポーネント(ModalFromHook)を作成した場合、その Custom Hook (useModal)が呼び出されるされる毎(※)に、作成されたコンポーネント(ModalFromHook)はアンマウントされてしまいます。
(※ 後述の通り、useCallbackでmemo化した場合は状態(isOpen)が変更される毎にアンマウントされます。)

実装例では視覚的に分かりやすくするために、Custom Hook (useModal)を利用しているコンポーネントを、1秒毎に強制的にレンダリングして、アンマウントされる現象を意図的に発生させています。また、Custom Hook useModalをレンダーする毎に"render"を、Modalコンポーネントがアンマウントされる毎に"unmount"を表示するようにしています。上記 codesandbox の console を見ると、1秒毎に"render"の直後に"unmount"が表示されていることから、Custom Hook (useModal)のレンダリング毎にアンマウントが発生していることが確認できます。

メモ化してもアンマウントされる

React.memo を利用した場合

このアンマウントされる現象は、Custom Hook (useModal)内で作成されるコンポーネント(ModalFromHook)をmemo化しても発生します。

React.useCallback を利用した場合

useCallback を用いた場合、Custom Hook (useModal)が呼び出される毎にアンマウントされることはなくなります。しかし、状態(isOpen)が変更される毎(モーダルを開いたり閉じたりする毎)にアンマウントされることが、 console にて "unmount" が表示されることから確認できます。
memoとのdiff

問題の考察

もし、Custom Hook から返却されるコンポーネントが、内部の状態を重視するコンポーネントである場合、アンマウントにより状態がリセットされるのは不都合です。さらに、マウントした時だけ実行するコード(データのフェッチなど)をコンポーネントに書いた場合、アンマウントが発生すると意図せずそのコードが実行される結果になりかねません。最初は簡単な実装だからアンマウントされても良いと考え、Component を返すrender hooks パターンを許容しても、後々複雑になり、問題を発生させる可能性もあります。 この時、デバッグを難しく感じるエンジニアもいるかもしれません。

React element を返却する render hooks パターン

アンマウントは発生しない

React elements を返却する render hooks パターンでは、アンマウントされる現象は発生しません。
下記の codesandbox で、 Console には "render" と繰り返し表示されていますが、"unmount" は表示されなくなっています。また、モーダルを開いてもチカチカしないことからもアンマウントされていないことが分かります。
コードのdiff

React element には props を渡せない問題について

Component とは違い、React element には props を渡すことができないので、呼び出す側(ModalButton)でモーダルコンポーネントの props を調整できません。呼び出す側(ModalButton)でモーダルコンポーネント(Modal)の props  を調整するため、以下の実装例では、Custom Hook (useModal)にモーダルコンポーネント(Modal)の props を渡す実装にしています。

https://github.com/fneco/render-hooks-pattern-with-chakra-ui/blob/return-element/src/components/ModalButton.tsx

感想

state を UI に閉じ込め、かつ、state の変更方法を制約する render hooks パターンの方向性は良いと思います。React は Custom Hook 内で Component を作成することを考慮して作られていないようなので、そのような実装は自分は避けたいなと思いますが、 element を返す render hooks パターンであれば採用はありだと思いました。Custom Hooks に Component の props を渡すのは少し違和感がありますが、違和感程度の問題と思います。また、呼び出し側で props を調整する必要がない場合、実装例のように Custom Hook に Component の props を渡す必要もありません。

Component を Custom Hook から返却する render hooks パターンでは、 Component の props の一部を(stateで)固定して新しい Component を作成しています。これは関数型プログラミングでいうところの部分適用だと思ってます。そうだとするとこの記事で指摘している注意点を別の表現にすると、「Custom Hook 内で Component を動的に部分適用するのは要注意」と表現できます。ところで、静的に Component に部分適用することは可能なようでして、それに関してはこちらに記事を書いているので興味があれば読んでいただければと思います。

Discussion

masakimasaki

このアンマウントされる現象は、Custom Hook (useModal)内で作成されるコンポーネント(ModalFromHook)をmemo化しても発生します

有益な記事の執筆ありがとうございます🙂
上記に関してなのですが、memo化が効かなかった理由はuseDisclosureの返値が再生成されているからではないでしょうか?
例えば、以下のような形式であればmemo化は機能するかと思われます

useModal
const ModalFromHook = useCallback(
  (props: Omit<ModalProps, "isOpen" | "onClose">) => {
    return <Modal {...{ isOpen, onClose }} {...props} />;
  },
  [isOpen, onClose]
);
useDisclosure
export function useDisclosure() {
  const [isOpen, setIsOpen] = useState(false);
  const onOpen = useCallback(() => setIsOpen(true), [setIsOpen]);
  const onClose = useCallback(() => setIsOpen(false), [setIsOpen]);

  return { isOpen, onOpen, onClose };
}
fnecofneco

こちらこそ記事を読んでいただき、また有益なご指摘をくださり、本当にありがとうございます。
ご指摘の点について、記事とソースコードを修正しましたので、ご確認いただけますと幸いです。

  • GitHubのソースコードを修正(useDisclosure周りのメモ化)の上、「React.useCallback を利用した場合」というセクションを追加
  • 「レンダリングされたら絶対にアンマウントされる」と受け取られないように文章を修正