Help us understand the problem. What is going on with this article?

React Hooksのカスタムフックが実現する世界 - オブジェクト指向とOSS

はじめに

React Hooksは、React 16.8 で追加された新機能であり、state などの React の機能を、クラスを書かずに使えるようになります。

React Hooksは、useState以外にもuseEffect、useContextなど合計10個の公式が提供する関数が存在しますが、今回はuseStateを中心に説明します。

カスタムフック

カスタムフックとは独自のフックを作成することです。
このときコンポーネントからロジックを抽出することで、ロジックを再利用することができるようになります。

またカスタムフックによりReactのロジックをOSSとして気軽に共有できる世界になりました。

この記事では、カスタムフックを利用してコードを改善していく例を紹介することで、オブジェクト指向設計が重要であること、カスタムフックが実現した世界を説明します。

今回の実例ではコンポーネントのコード量は以下のように削減されます。

all.png

実例紹介

コンポーネント間のページネーションを実装するuseLocalHistoryカスタムフックを作成します。
ブラウザのhistory APIのようなものです。

実行サンプル
スクリーンショット 2019-08-04 9.36.19.png

https://oh7c3.csb.app/

v1 カスタムフック未使用

このコンポーネントはViewとロジックが混在しているため、読みづらく、ロジックのテストが難しいコードとなっています。

Page.tsx
import React, { useState } from "react";

export const Page = () => {
  const topPage = 1;
  const lastPage = 4;
  const initHistory: number[] = [topPage];
  const [history, setHistory] = useState<number[]>(initHistory);

  const currentPage = history[history.length - 1];

  return (
    <div>
      <div>現在のページ: {currentPage}</div>
      <button
        onClick={() => {
          // 現在トップページの場合は移動しない
          if (currentPage !== topPage) {
            const nextHistory = [...history, topPage];
            setHistory(nextHistory);
          }
        }}
      >
        トップ
      </button>
      <button
        onClick={() => {
          const nextPage = currentPage + 1;
          // ラストページより先には進めない
          if (nextPage <= lastPage) {
            const nextHistory = [...history, nextPage];
            setHistory(nextHistory);
          }
        }}
      >
        次へ
      </button>
      <button
        onClick={() => {
          // トップページより前には戻れない
          if (history.length > 1) {
            const nextHistory = [...history.slice(0, history.length - 1)];
            setHistory(nextHistory);
          }
        }}
      >
        戻る
      </button>
      <button
        onClick={() => {
          // 現在ラストページの場合は移動しない
          if (currentPage !== lastPage) {
            const nextHistory = [...history, lastPage];
            setHistory(nextHistory);
          }
        }}
      >
        ラスト
      </button>
      <button
        onClick={() => {
          setHistory(initHistory);
        }}
      >
        履歴を消去
      </button>
    </div>
  );
};

https://codesandbox.io/embed/custom-hook-v1-oh7c3

v2 カスタムフック

コンポーネントからロジックをカスタムフックに分離します。

コンポーネントが必要な情報は以下だけなので、historyはカスタムフックに隠蔽します。

  • 値は、currentPage
  • 操作は、Top、Next、Back、Last、Reset
Page.tsx
import React from "react";
import { useLocalHistory } from "./useLocalHistory";

export const Page: React.FC = () => {
  const topPage = 1;
  const lastPage = 4;

  const [currentPage, Top, Next, Back, Last, Reset] = useLocalHistory(
    topPage,
    lastPage
  );

  return (
    <div>
      <div>現在のページ: {currentPage}</div>
      <button onClick={Top}>トップ</button>
      <button onClick={Next}>次へ</button>
      <button onClick={Back}>戻る</button>
      <button onClick={Last}>ラスト</button>
      <button onClick={Reset}>リセット</button>
    </div>
  );
};
useLocalHistory.ts
import { useState } from "react";

export const useLocalHistory = (
  topPage: number,
  lastPage: number
): [number, () => void, () => void, () => void, () => void, () => void] => {
  const initHistory: number[] = [topPage];
  const [history, setHistory] = useState<number[]>(initHistory);

  const currentPage = history[history.length - 1];

  const Top = (): void => {
    // 現在トップページの場合は移動しない
    if (currentPage !== topPage) {
      const nextHistory = [...history, topPage];
      setHistory(nextHistory);
    }
  };

  const Next = (): void => {
    const nextPage = currentPage + 1;

    // ラストページより先には進めない
    if (nextPage <= lastPage) {
      const nextHistory = [...history, nextPage];
      setHistory(nextHistory);
    }
  };

  const Back = (): void => {
    // トップページより前には戻れない
    if (history.length > 1) {
      const nextHistory = [...history.slice(0, history.length - 1)];
      setHistory(nextHistory);
    }
  };

  const Last = (): void => {
    // 現在がラストページの場合は移動しない
    if (currentPage !== lastPage) {
      const nextHistory = [...history, lastPage];
      setHistory(nextHistory);
    }
  };

  const Reset = (): void => {
    setHistory(initHistory);
  };

  return [currentPage, Top, Next, Back, Last, Reset];
};

https://codesandbox.io/embed/custom-hook-v2-419xb

PageコンポーネントはViewに関連する実装が中心となり、とても読みやすくなりました。

v3 インターフェースの定義

履歴機能を提供する LocalHistory インターフェースを useLocalHistory.ts に定義します。
PageコンポーネントはLocalHistory インターフェースを介して操作をします。

Page.tsx
import React from "react";
import { useLocalHistory } from "../../utils/useLocalHistory";

export const Page: React.FC = () => {
  const topPage = 1;
  const lastPage = 4;

  const [currentPage, history] = useLocalHistory(topPage, lastPage);

  return (
    <div>
      <div>現在のページ: {currentPage}</div>
      <button onClick={history.Top}>トップ</button>
      <button onClick={history.Next}>次へ</button>
      <button onClick={history.Back}>戻る</button>
      <button onClick={history.Last}>ラスト</button>
      <button onClick={history.Reset}>リセット</button>
    </div>
  );
};
useLocalHistory.ts
import { useState } from "react";

interface LocalHistory {
  Top: () => void;
  Next: () => void;
  Back: () => void;
  Last: () => void;
  Reset: () => void;
}

export const useLocalHistory = (
  topPage: number,
  lastPage: number
): [number, LocalHistory] => {
  const initHistory: number[] = [topPage];
  const [history, setHistory] = useState<number[]>(initHistory);

  const currentPage = history[history.length - 1];

  const Top = (): void => {
    // 現在トップページの場合は移動しない
    if (currentPage !== topPage) {
      const nextHistory = [...history, topPage];
      setHistory(nextHistory);
    }
  };

  const Next = (): void => {
    const nextPage = currentPage + 1;

    // ラストページより先には進めない
    if (nextPage <= lastPage) {
      const nextHistory = [...history, nextPage];
      setHistory(nextHistory);
    }
  };

  const Back = (): void => {
    // トップページより前には戻れない
    if (history.length > 1) {
      const nextHistory = [...history.slice(0, history.length - 1)];
      setHistory(nextHistory);
    }
  };

  const Last = (): void => {
    // 現在がラストページの場合は移動しない
    if (currentPage !== lastPage) {
      const nextHistory = [...history, lastPage];
      setHistory(nextHistory);
    }
  };

  const Reset = (): void => {
    setHistory(initHistory);
  };

  return [currentPage, { Top, Next, Back, Last, Reset }];
};

https://codesandbox.io/s/custom-hook-v3-8hsiu

LocalHistoryインターフェースを定義することで、一連の操作の関連が明確になりました。
また一連の操作を他のコンポーネントに渡すことが容易になりました。

v4 データ構造を独立したカスタムフックに分離

LocalHistoryはStack(LIFO)のデータ構造で実現されています。
これをuseStackカスタムフックとして切り出します。
カスタムフックは多段構成が可能なため、useLocalHistoryから切り出したuseStackを実行します。

Pageコンポーネントは変わらないため省略します。

useLocalHistory.ts
import { useStack } from "./useStack";

interface LocalHistory {
  Top: () => void;
  Next: () => void;
  Back: () => void;
  Last: () => void;
  Reset: () => void;
}

// useLocalHistoryはStackデータ構造の実装詳細がを意識しない
export const useLocalHistory = (
  topPage: number,
  lastPage: number
): [number, LocalHistory] => {
  const initHistory: number[] = [topPage];
  const [currentPage, stack] = useStack<number>(initHistory);

  const Top = (): void => {
    // 現在トップページの場合は移動しない
    if (currentPage !== topPage) {
      stack.Push(topPage);
    }
  };

  const Next = (): void => {
    const nextPage = currentPage + 1;

    // ラストページより先には進めない
    if (nextPage <= lastPage) {
      stack.Push(nextPage);
    }
  };

  const Back = (): void => {
    // トップページより前には戻れない
    stack.Pop();
  };

  const Last = (): void => {
    // 現在がラストページの場合は移動しない
    if (currentPage !== lastPage) {
      stack.Push(lastPage);
    }
  };

  const Reset = (): void => {
    stack.Reset();
  };

  return [currentPage, { Top, Next, Back, Last, Reset }];
};
useStack.ts
import { useState } from "react";

export interface Stack<T> {
  Pop: () => T | undefined;
  Push: (v: T) => void;
  Reset: () => void;
}

// Stackのデータ構造をカスタムフックとして定義する
export const useStack = <T>(init?: T[]): [T, Stack<T>] => {
  const initStack: T[] = init !== undefined ? init : [];
  const [stack, setStack] = useState<T[]>(initStack);

  const Pop = (): T | undefined => {
    if (stack.length <= 1) {
      return undefined;
    }

    const newStack = [...stack.slice(0, stack.length - 1)];
    setStack(newStack);

    return newStack[newStack.length - 1];
  };

  const Push = (v: T): void => {
    const newStack = [...stack, v];
    setStack(newStack);
  };

  const Reset = (): void => {
    setStack(initStack);
  };

  return [stack[stack.length - 1], { Pop, Push, Reset }];
};

https://codesandbox.io/s/custom-hook-v4-8b1n7

これによりuseLocalHistoryがStackの実装詳細を意識せず、画面遷移の制御だけをロジックとして持つようになりました。

v5 ContainerコンポーネントとPresentationalコンポーネントの分離

副作用を起こすレイヤーを分離します。
Reduxではconnectを実行するレイヤーを分離することがフレームワークで強制されていますが、これと同じ設計方針です。

useLocalHistoryとuseStackは同様のため省略します。

Page.tsx
import React from "react";
import { LocalHistory, useLocalHistory } from "./useLocalHistory";

// Containerコンポーネント
export const PageContainer: React.FC = () => {
  const topPage = 1;
  const lastPage = 4;

  const [currentPage, history] = useLocalHistory(topPage, lastPage);
  return <Page currentPage={currentPage} history={history} />;
};

interface PageProps {
  currentPage: number;
  history: LocalHistory;
}

// Presentationalコンポーネント
const Page: React.FC<PageProps> = ({ currentPage, history }: PageProps) => {
  return (
    <div>
      <div>現在のページ: {currentPage}</div>
      <button onClick={history.Top}>トップ</button>
      <button onClick={history.Next}>次へ</button>
      <button onClick={history.Back}>戻る</button>
      <button onClick={history.Last}>ラスト</button>
      <button onClick={history.Reset}>リセット</button>
    </div>
  );
};

https://codesandbox.io/embed/custom-hook-v5-nqwev

これによりPageコンポーネントは引数を受けて返り値を返すという純粋な関数になりました。
Viewが純粋な関数になるというのは昔のGUI開発では考えられない素晴らしいことです。

まとめ

v1からv5にかけて以下のように改善されました。

  • コンポーネントからロジックが分離 (Presentation Domain Separation)
  • 一般的なデータ構造をロジックから分離 委譲(delegation)
  • history履歴がPageコンポーネントには渡らない 情報隠蔽(カプセル化)
  • インターフェースの定義 開放閉鎖原則(OCP)
  • Pageコンポーネントの関数化 副作用を内部で取得せず引数として受け取る
  • 各モジュールの設計意図が明確化 単一責任の原則(SRP)

これらにより再利用性、可読性、テスタビリティが向上します。

このようにReact Hooksのカスタムフックの登場により、オブジェクト指向のノウハウが活用できるようになりました。状態と実装の詳細をカプセル化することができ、コンポーネントに必要な値とインターフェースだけを公開することが可能となります。

PDS、委譲、カプセル化、SOLID原則(OCP, SRP)などのとおり、今まで培われてきたオブジェクト指向設計と何も変わらない普遍的な設計能力が必要とされます。 React Hooks特有の設計能力が求められるものではありません。

実際にカスタムフックとクラスを以下のように比較してみると、同様であることが理解できると思います。

  • クラス
    • 状態 :メンバフィールド
    • 操作: メソッド
    • 初期化: コンストラクタ
  • カスタムフック
    • 状態: useState
    • 操作: 関数
    • 初期化: フックの引数

実装されたコードを比較してみても基本的な表現に大きな違いはありません。

React Hooksが実現する世界

今まではReactのロジックがReactコンポーネントのライフサイクルに依存し、ロジック単独でのモジュール化が難しく再利用性が低い問題がありました。しかしReact Hooksの登場で改善されます。

そのため今回開発したuseStackやuseLocalHistoryはViewに依存していないため、みなさんのユースケースにあわせて利用することが可能です。

このように ReactのロジックをOSSとして気軽に共有できる世界になりました。

これがコードを綺麗に設計できるようになったこと以上の最大のメリットです。

OSS

これからは状態に基づく処理を自分で書く前にOSSのReact Hooksを調べてみることをお勧めします。
恐らく自分が思いついた実装よりもOSSとして洗練された良い実装が見つかるはずです。
また自分で実装する場合にもとても参考になります。

追記

コンポーネントの再描画を抑制するために、useCallbackとuseMemoを利用することができます。
これによりuseLocalHistoryを利用しているコンポーネントが実行されるたびに、コールバック定義が再実行されることを回避します。
LocalHistoryインターフェースを別のコンポーネントに渡している場合などには有効です。
しかし今回の記事で述べたいこととはずれるので詳細は省略します。

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away