👨‍💻

カスタムフックへロジックを切り出して、適切な責務の分割をしよう

2023/11/30に公開

Reactでコンポーネントを記述していたら、巨大で読みにくいファイルになってしまった経験はないでしょうか?もし心当たりがあったら、責務の分割を意識してみると改善できるかもしれません。

背景

筆者はTypescript環境でReactを用いてフロントエンドを構築する作業を主な生業としていますが、おおよそ下記のようなアプローチをしています。

  1. Routerによってページコンポーネントを実行
  2. ページコンポーネントでデータをフェッチ
  3. フェッチしたデータをロジックをまとめたカスタムフックに投入
  4. カスタムフックは表示用コンポーネントが自然に扱えるように整理・整形された値を返す
  5. カスタムフックから受け取った値をpropsとしてUIを構築
type Props = {
    pageId: number;
};

const Page = ({ pageId }: Props) => {
    const { data: queryResult } = useQuery({ pageId }); // データフェッチ
    const data = usePage({ queryResult }); // カスタムフック

    // ロード中の代替表示やエラーハンドリングなど...

    return <UserInterface {...data} />;
};

データ(情報)とビュー(画面表示)が分離されることはReactに限らず多くの技術体系で推奨される設計であると思いますが、私も似たような考え方をしています。Reactの単方向データフローの原則に則った、比較的よく見かけるシンプルなアプローチではないでしょうか。

この場合、3番のカスタムフックの内部にビジネスロジックが凝集されることになりますから、データフェッチやUIコンポーネントはその純粋性を保つことができ、良い意味でビジネスロジックに関心を寄せなくて済むようになります。テストも簡潔に書けますし、流用性も高いでしょう。

では、ビジネスロジックが凝集されたカスタムフックとはいかなる存在なのでしょうか...? それが今回の主な話題です。

ビジネスロジックが凝集されたカスタムフックとは

Reactでコンポーネントを作成するとき、主にJSXを用いた表示の制御部分と、さまざまな状態をステートで管理したり関数に切り出したりする部分に別れると思います。

それぞれの部分がそれなりの大きさ(=記述量)になってくると、コードの見通しが悪くなってきます。どんな処理をどこで行なっているかの認識が難しくなっていき、作業の効率に影響することも多いでしょう。特に初見では影響が顕著となり、レビュワーにとっては大きなストレスになりそうです。

type Props = {
  huge: string;
}

export const HugeComponent = ({ huge }: Props) => {
    // ステートや関数による制御部分
    const name = useMemo<string>(() => {
        // Lorem ipsum dolor sit amet,
	// consectetur adipiscing elit,
	// sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
	// Ut enim ad minim veniam,
	// quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
	// Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.
	// Excepteur sint occaecat cupidatat non proident,
	// sunt in culpa qui officia deserunt mollit anim id est laborum.
        return huge;
    }, [huge]);
    
    const something = useMemo<string>(() => {
        // Lorem ipsum dolor sit amet,
	// consectetur adipiscing elit,
	// sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
	// Ut enim ad minim veniam,
	// quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
	// Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.
	// Excepteur sint occaecat cupidatat non proident,
	// sunt in culpa qui officia deserunt mollit anim id est laborum.
        return "something";
    }, []);
    
    const anything = useMemo<string>(() => {
        // Lorem ipsum dolor sit amet,
	// consectetur adipiscing elit,
	// sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
	// Ut enim ad minim veniam,
	// quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
	// Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.
	// Excepteur sint occaecat cupidatat non proident,
	// sunt in culpa qui officia deserunt mollit anim id est laborum.
        return "anything";
    }, []);
    
    const handleSomething = useCallback(() => {
        // Lorem ipsum dolor sit amet,
	// consectetur adipiscing elit,
	// sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
	// Ut enim ad minim veniam,
	// quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
	// Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.
	// Excepteur sint occaecat cupidatat non proident,
	// sunt in culpa qui officia deserunt mollit anim id est laborum.
        console.log(something);
    }, [something]);

    const handleAnything = useCallback(() => {
        // Lorem ipsum dolor sit amet,
	// consectetur adipiscing elit,
	// sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
	// Ut enim ad minim veniam,
	// quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
	// Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.
	// Excepteur sint occaecat cupidatat non proident,
	// sunt in culpa qui officia deserunt mollit anim id est laborum.
        console.log(anything);
    }, [anything]);

    // JSXによる表示部分
    return (
        <div>
          <p>{name}</p>
          <button onClick={handleSomething}>{something}</button>
	  <button onClick={handleAnything}>{anything}</button>
        </div>
    );
};

上記の例では、(無駄なコメントによって)記述量が膨張しています。
なんとなく明快さに欠け、読みにくさを感じるのではないでしょうか。

では第三者が書いたこのファイルを、あなたが初見で編集しなければならない事態を考えてみてください。主にどんな編集をしようとしているのかで、関心は2つに分かれるのではないでしょうか。

  1. ボタンを押した時の動作などの、処理や機能を編集したい
  2. ボタンの大きさや文字の色など、表示のスタイルを編集したい

1の場合はステートや関数による制御部分を、2の場合はJSXによる表示部分を編集することになると思いますが、この 「作業意図によって関心を寄せる箇所が異なる」 という事実は、 コードが明快さに欠ける要因として、複数の責務が同居することによる混沌が影響している ことを示唆していると考えることができるのではないでしょうか。

責務(関心)の分離

プログラムの責務が適切に分離されることはReactに限らず多くの技術体系で推奨される設計であると思います。ソフトウェア工学で広く知られた概念であり、既に優れた記事がたくさんありますので、改めて説明はしません。

https://qiita.com/kamykn/items/3265ad2dfd91dc7e973f

分割してみよう

では実際に分割してみます。

HugeComponent.tsx
type Props = {
    huge: string;
};

export const HugeComponent = ({ huge }: Props) => {
    // ステートや関数による制御部分
    const { name, something, anything, handleSomething, handleAnything } =
        useHugeComponent({ huge });

    // JSXによる表示部分
    return (
        <div>
            <p>{name}</p>
            <button onClick={handleSomething}>{something}</button>
            <button onClick={handleAnything}>{anything}</button>
        </div>
    );
};

スッキリしましたね!

useHugeComponentというカスタムフックを新設し、各種パラメータをカスタムフックの内部に移動しただけです。なんの驚きもない記述位置の移動でしかないわけですが、これによって下記のように責務が分割されました。

  • useHugeComponentはパラメータや関数を生成することが責務であり、返却した値がどこでどのように使われるのかについては知らないし、関心を寄せていない
  • HugeComponentは主に表示を司ることが責務であり、something等のパラメータの中身が正しいのか、handleAnythingの処理が適切かなどについては知らないし、関心を寄せていない
  1. ボタンを押した時の動作などの、処理や機能を編集したい
  2. ボタンの大きさや文字の色など、表示のスタイルを編集したい

前述の1のケースではuseHugeComponentを編集し、2のケースではHugeComponentを編集すればいいわけですね。ちなみにuseHugeComponentは下記のようになります。

useHugeComponent.ts
type Props = {
    huge: string;
};

export const useHugeComponent = ({ huge }: Props) => {
    const name = useMemo<string>(() => {
        // ...省略...
        return huge;
    }, [huge]);

    const something = useMemo<string>(() => {
        // ...省略...
        return "something";
    }, []);

    const anything = useMemo<string>(() => {
        // ...省略...
        return "anything";
    }, []);

    const handleSomething = useCallback(() => {
        // ...省略...
        console.log(something);
    }, [something]);

    const handleAnything = useCallback(() => {
        // ...省略...
        console.log(anything);
    }, [anything]);

    return {
        name,
        something,
        anything,
        handleSomething,
        handleAnything,
    };
};

はい、難しいことは何もありません。

ちなみに、上記の例ではコンポーネントの内部でカスタムフックを実行していますが、これも外に出すことで完全に切り分けることができます。

HugeComponent.tsx
type Props = ReturnType<typeof useHugeComponent>;

export const HugeComponent = ({
    name,
    something,
    anything,
    handleSomething,
    handleAnything,
}: Props) => {
    return (
        <div>
            <p>{name}</p>
            <button onClick={handleSomething}>{something}</button>
            <button onClick={handleAnything}>{anything}</button>
        </div>
    );
};
const props = useHugeComponent({ huge });
return <HugeComponent {...props} />

汎用部品などの場合にはこの方法のほうが使いやすいでしょう。

メリットは大きい

カスタムフックに切り出すことには他にもメリットがあります。

再利用性が高まる

同じパラメータを使用したレイアウト違い...などのケースではロジックを共有できるため、再利用性を高めることができます。

export const SuperHugeComponent = ({ huge }: Props) => {
    // useHugeComponentを共用できる
    const { name, something, handleSomething } = useHugeComponent({ huge });
    
    return (
        <div>
          <p>I am Super!</p>
          <p>{name}</p>
          <button onClick={handleSomething}>{something}</button>
        </div>
    );
}

export const GreatHugeComponent = ({ huge }: Props) => {
    // useHugeComponentを共用できる
    const { name, something, handleSomething } = useHugeComponent({ huge });
    
    return (
        <div>
          <p>I am Great!</p>
          <p>{name}</p>
          <button onClick={handleSomething}>{something}</button>
        </div>
    );
}

テストができる

カスタムフックに切り出さず、コンポーネントに直接書かれた変数や関数は、普通の方法では外部から参照することはできません。これは単体テストが難しいことを意味します。

HugeComponent.tsx
export const HugeComponent = ({ huge }: Props) => {
    // nameを外部から参照することはできないので、値のテストは難しい
    const name = useMemo<string>(() => {
        return huge;
    }, [huge]);
    // ...省略...	
}

一方カスタムフックは単なる関数であるため、シンプルにテスト可能です。
精度やメンテナンスに課題の残るDOMテストではなく、純粋に値をプリミティブに検証できます。
カスタムフックに切り出したことで 「値が責務」の関数に分割されたからですね。

useHugeComponent.ts
export const useHugeComponent = ({ huge }: Props) => {
    // ...省略...
    return { name }; // 関数から値としてreturnされているので、シンプルにテスト可能
}
HugeComponent.spec.tsx
 const { result } = renderHook(() => useHugeComponent({ huge: "huge" }));
// Jest等で値自体をテストできる
 expect(result.current.data.name).toEqual("huge");

レビュワーにも優しい

レビュワーは常に「他人が書いたコード」を読むことになります。
適切に責務が分割されたコードはリーダブルであり、レビュワーにとっても優しいものになります。

みんなに優しいコードのために

複雑なものを複雑なまま扱い、摩訶不思議な実装になってしまった経験は、フロントエンドに携わる人間ならば誰しも一度はあるのではないでしょうか。適切な責務分割を行うことによって構造をシンプルに保つことができ、再利用性と易編集性を高めながら、レビュワーにも優しいコードを書くことができると思います。

筆者はレビュアーやシニアポジションとして後進の育成を受け持つことも珍しくありませんので、Zennの記事も自身の知見を再利用性の高い方法で切り出した結果と言えます。なんちゃって。

もっといい方法があるぜ!とか、わたしはこうしてるよ!とかがあれば是非教えてくださいね。

Discussion