グローバルナビゲーションへ

本文へ

フッターへ

お役立ち情報Blog



Beyond the render hooks (return component from hooks) pattern. ~それでもReactでReact.FCを返すカスタムフックを作りたい!!~

「ハンマーを持つ人にはすべてが釘に見える」という格言に耳が痛いと思う今日この頃です。

みなさん render hooks パターン使ってますか。

今回は記事タイトルの通り React.FC を返すカスタムフックを作りたい!! という筆者の願望を叶えるべく試行錯誤した内容をお送りします。

render hooks パターンとは

【LINE証券 FrontEnd】コンポーネントをカスタムフックで提供してみた

ステートとロジックをカスタムフックに閉じ込め、返り値にコンポーネント (※1) を含めることで、凝集性が高まり、関心(責務)が分離されるわけですね。

※1 ここでのコンポーネントとは React.FC ではなく JSX.Element の事を指しています

React.FC ではなく JSX.Element を返すことを推奨している

 JSX.Element を返すことを推奨しています。

React.FCを返すほうはあまり良くないと思う。というのもステートを内包させると必然的にステートが変わると別の関数オブジェクトになり、再レンダリング時にパフォーマンスのペナルティがあるから🫐

仰る通り。

それでも React.FC コンポーネントを返したい

render hooks パターンをモーダルダイアログに適用できそうと考えましたが、モーダルダイアログを使用する側の視点で考えたときに問題が。

モーダルダイアログを使う側はこのような疑似コードになるでしょう。

export const App: FC = () => {
  // render hooks パターンを適用した useDialog カスタムフック
  const [Dialog, open, close] = useDialog();

  return (
    <>
      <button onClick={open}>open</button>
      <Dialog>
        <div>
          <p>ここにダイアログのコンテンツを入れたいんじゃ!!</p>
          <button onClick={close}>close</button>
        </div>
      </Dialog>
    </>
  )
}

おわかりいただけただろうか?

誰も JSX.Element を返していないのである。

 useDialog カスタムフックが返すコンポーネントが JSX.Element ではなく React.FC になっています。 この時の筆者は筋が悪いのは分かりつつも React.FC を返した方が使い勝手がいいよなーと安易に React.FC を返す設計にしたわけです。

src/Dialog/useDialog.tsx

import { useCallback, useMemo, useState } from "react";

import type { FC, ReactNode } from "react";

import styles from "./Dialog.module.css";

type DialogProps = {
  readonly children: ReactNode;
};

export const useDialog = () => {
  const [isOpen, setIsOpen] = useState(false);
  const open = useCallback(() => setIsOpen(true), []);
  const close = useCallback(() => setIsOpen(false), []);

  const Dialog: FC<DialogProps> = useMemo(() => {
    return function Dialog({ children }) {
      return isOpen ? (
        <div className={styles.backdrop}>
          <div role="dialog" className={styles.dialog}>
            {children}
          </div>
        </div>
      ) : null;
    };
  }, [isOpen]);

  return [Dialog, open, close] as const;
};

このような形でカスタムフックを定義しました。早速ブラウザで確認してみます。

いい感じですね。

React.FCを返すパターンにCSSアニメーションを追加

せっかくなのでダイアログを開く・閉じるときにアニメーションもつけましょう。

Reactでアニメーションを実装する方法は以前の記事でご紹介していますのでこちらをご参照ください。
React-transition-groupでモーダルアニメーションを実装する!

React Transition Group を使ってアニメーションを実装していきます。

/src/Dialog/useDialog.tsx

import { useCallback, useMemo, useRef, useState } from "react";
import { CSSTransition } from "react-transition-group";

import type { FC, ReactNode } from "react";

import styles from "./Dialog.module.css";

type DialogProps = {
  readonly children: ReactNode;
};

export const useDialog = () => {
  const [isOpen, setIsOpen] = useState(false);
  const open = useCallback(() => setIsOpen(true), []);
  const close = useCallback(() => setIsOpen(false), []);

  const Dialog: FC<DialogProps> = useMemo(() => {
    return function Dialog({ children }) {
      // @see https://reactcommunity.org/react-transition-group/css-transition
      const ref = useRef<HTMLDivElement | null>(null);

      return (
        <CSSTransition
          in={isOpen}
          timeout={300}
          nodeRef={ref}
          onExited={close}
          unmountOnExit
          classNames={{
            enter: styles.enter,
            enterActive: styles["enter-active"],
            exit: styles.exit,
            exitActive: styles["exit-active"],
          }}
        >
          <div ref={ref} className={styles.backdrop}>
            <div role="dialog" className={styles.dialog}>
              {children}
            </div>
          </div>
        </CSSTransition>
      );
    };
  }, [isOpen]);

  return [Dialog, open, close] as const;
};

アニメーションも実装できたし、ブラウザで確認して今夜も祝杯だっと・・・

実装できていませんでした。

なぜCSSアニメーションが効かないのか

カスタムフックが返す React.FC 内でマウントとアンマウントの確認をしてみます。

-import { useCallback, useMemo, useRef, useState } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
 import { CSSTransition } from "react-transition-group";

 import type { FC, ReactNode } from "react";
@@ -19,6 +19,13 @@ export const useDialog = () => {
       // @see https://reactcommunity.org/react-transition-group/css-transition
       const ref = useRef<HTMLDivElement | null>(null);

+      useEffect(() => {
+        console.log("mount: Dialog");
+
+        return () => {
+          console.log("unmount: Dialog");
+        };
+      }, []);
       return (
         <CSSTransition
           in={isOpen}

open, closeボタンを押す( isOpen ステートが変わる)毎にReactのアンマウントが実行されているのが分かります。

このステート変更をトリガーに React Transition Group の CSSTransition を使ったアニメーションが実行される前に、強制的にアンマウントが実行されてしまいアニメーションが効かない状態となってしまいました。これは辛いです。

React.FCを返すことによる問題

ここまでの問題の流れを一旦整理します。

  •  isOpen ステートが変更される
  •  isOpen ステートの変更を検知してReactが Dialog: React.FC コンポーネントのアンマウント処理を実行する
  •  Dialog: React.FC コンポーネントがマウントされる
  • インスタンスが違うので、CSSアニメーションが走らない

React.FCを返すほうはあまり良くないと思う。というのもステートを内包させると必然的にステートが変わると別の関数オブジェクトになり、再レンダリング時にパフォーマンスのペナルティがあるから🫐

※再掲:仰る通り。

これを解決するには Dialog: React.FC コンポーネント定義を静的(カスタムフック外)に定義すればよさそうですが、 isOpen ステートはカスタムフック内に定義されているのでどうやって参照しようというジレンマ。

ちなみに render hooks パターン( JSX.Element )を返すパターンの場合はこの問題は発生しませんが、使い勝手を考えると Props に children を含めたい・・・

return component from hooks

そこで調べてみたところズバリな記事を見つけました。

React JS Design Patterns: Return Component From Hooks | Bits and Pieces

これを解決するには Dialog: React.FC コンポーネント定義を静的(カスタムフック外)に定義すればよさそうですが、 isOpen ステートはカスタムフック内に定義されているのでどうやって参照しようというジレンマ。

この対策として参考記事ではReactのContext経由で isOpen ステートを参照することで解決を図っています。

ただ使うときにContextProviderでラップしないといけないのが玉に瑕です。帯に短し襷に長し。

今回取り入れたパターン

 <Menu {…menuProps}> 

This pattern is already used by many hook-based libraries such as react-table and react-hook-form.

参考記事のこのエッセンスを取り入れつつ、カスタムフック内で React.FC コンポーネント定義をすることをやめることにして、 ダイアログの React.FC を静的に定義し、カスタムフックの返り値で React.FC と Props をセットで返すことにしました。

/src/Dialog/useDialog.tsx

import { useCallback, useRef, useState } from "react";
import { CSSTransition } from "react-transition-group";

import type { FC, ReactNode } from "react";

import styles from "./Dialog.module.css";

type DialogProps = {
  readonly isOpen: boolean;
  readonly children: ReactNode;
  readonly close: () => void;
};

const Dialog: FC<DialogProps> = ({ isOpen, close, children }) => {
  // @see https://reactcommunity.org/react-transition-group/css-transition
  const ref = useRef<HTMLDivElement | null>(null);

  return (
    <CSSTransition
      in={isOpen}
      timeout={300}
      nodeRef={ref}
      onExited={close}
      unmountOnExit
      classNames={{
        enter: styles.enter,
        enterActive: styles["enter-active"],
        exit: styles.exit,
        exitActive: styles["exit-active"],
      }}
    >
      <div ref={ref} className={styles.backdrop}>
        <div role="dialog" className={styles.dialog}>
          {children}
        </div>
      </div>
    </CSSTransition>
  );
};

type OpenDialog = () => void;
type CloseDialog = () => void;

type UseDialogReturn = readonly [
  readonly [typeof Dialog, Omit<DialogProps, "children">],
  readonly [OpenDialog, CloseDialog]
];

export const useDialog = (): UseDialogReturn => {
  const [isOpen, setIsOpen] = useState(false);
  const open = useCallback(() => setIsOpen(true), []);
  const close = useCallback(() => setIsOpen(false), []);

  return [
    [Dialog, { isOpen, close }],
    [open, close],
  ] as const;
};

使う側のコンポーネントはこのような書き味になります。

/src/App.tsx

import type { FC } from "react";
import { useDialog } from "./Dialog";

import "./App.css";

export const App: FC = () => {
  const [[Dialog, dialogProps], [open, close]] = useDialog();

  return (
    <div>
      <p>openボタンを押してダイアログを開きます</p>
      <div style={{ height: "200px" }} />
      <button onClick={open}>open</button>
      <Dialog {...dialogProps}>
        <div>
          <p>ここにダイアログのコンテンツを入れたいんじゃ!!</p>
          <button onClick={close}>close</button>
        </div>
      </Dialog>
    </div>
  );
};

まとめ

今回ご紹介したパターンでは、タプル内にさらにタプルを定義しているのでカスタムフックの使い方が初見殺しという大きなデメリットがあります。

これはカスタムフックの使い方をテストで明示することである程度は緩和できますが、もっとシンプルな方法があればそちらへの乗り換えも要検討ですね。

最後に今回参考にさせていただいた記事を再掲します。

雨ニモマケズ

風ニモマケズ

こういうパターンをスッと思いつける開発者

サウイフモノニ

ワタシハナリタイ

余談

 React.FC 定義をカスタムフック内に定義しても、使う側のコンポーネントで関数呼び出しを行うと再レンダリングを防げます。

export const App: FC = () => {
  const [Dialog, open, close] = useDialog();

  return (
    <div>
      <p>openボタンを押してダイアログを開きます</p>
      <div style={{ height: "200px" }} />
      <button onClick={open}>open</button>
      {Dialog({
        children: (
          <div>
            <p>ここにダイアログのコンテンツを入れたいんじゃ!!</p>
            <button onClick={close}>close</button>
          </div>
        ),
      })
      }
    </div>
  );
};

これは調べてないので予想になりますが、関数呼び出しでReactの差分検出処理がスキップされる、つまり再レンダリングの責務がReactから開発者側に移っていると筆者は感じたので筋が悪いかなと思います。

この記事を書いた人

美髭公
美髭公事業開発部 web application engineer
2013年にアーティスに入社。システムエンジニアとしてアーティスCMSを使用したWebサイトや受託システムの構築・保守に携わる。環境構築が好き。
この記事のカテゴリ

FOLLOW US

最新の情報をお届けします