LoginSignup
557
425

More than 3 years have passed since last update.

React Hooksとカスタムフックが実現する世界 - ロジックの分離と再利用性の向上

Last updated at Posted at 2019-08-05

はじめに

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

React Hooks以前は、ロジックの再利用がコンポーネントに依存してしまいロジック単独でのモジュール化が難しいという問題がありました。
しかしReact Hooksのカスタムフックという独自のフックを作成する機能を使うことで、Viewに依存することなくロジックだけを再利用することができるようになります。

この記事では、v1からv6まで改善していく様子を見て頂くことでReact Hooksの利用方法を紹介します。

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

all.png

実例紹介

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

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

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

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) {
      return;
    }
    const nextHistory = [...history, topPage];
    setHistory(nextHistory);
  };

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

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

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

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

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

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

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) {
      return;
    }
    const nextHistory = [...history, topPage];
    setHistory(nextHistory);
  };

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

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

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

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

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

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

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

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

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

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

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

export 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 [currentPage, stack] = useStack<number>(initHistory);

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

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

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

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

  const Last = (): void => {
    // 現在がラストページの場合は移動しない
    if (currentPage === lastPage) {
      return;
    }
    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: () => void;
  Push: (v: T) => void;
  Reset: () => void;
  Length: () => number;
}

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

  const Pop = (): void => {
    if (stack.length === 0) {
      return;
    }

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

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

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

  const Length = (): number => stack.length;

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

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

また詳しくは説明しませんが、setStateには以前の状態を受け取り更新する方法もあります。
https://codesandbox.io/s/custom-hook-v41-hokuq

v5 useStateの代わりにuseReducerを利用する

v4の useStack では、配列に追加や削除をするために現在の状態を知っている必要があります。
これでも問題はないのですが、配列やオブジェクトの一部を操作する場合のように前回の状態に依存した更新処理をする場合には useState の代わりに useReducer を利用することで、より簡潔に記述することができるようになります。

useStack.ts
import { useReducer } from "react";

type StackState<T> = T[];

type StackAction<T> =
  | { type: "ACTION_POP" }
  | { type: "ACTION_PUSH"; value: T }
  | { type: "ACTION_RESET"; initStack: T[] };

const stackReducer = <T>() => (
  stack: StackState<T>,
  action: StackAction<T>
): StackState<T> => {
  switch (action.type) {
    case "ACTION_POP":
      if (stack.length === 0) {
        return stack;
      }
      return [...stack.slice(0, stack.length - 1)];
    case "ACTION_PUSH":
      return [...stack, action.value];
    case "ACTION_RESET":
      return action.initStack;
  }
};

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

export const useStack = <T>(init?: T[]): [T, Stack<T>] => {
  const initStack: T[] = init ?? [];
  const [stack, dispatch] = useReducer(stackReducer<T>(), initStack);

  // 前回の状態は必要なく、実行するActionとActionに必要な値だけが必要となる。
  const Pop = (): void => dispatch({ type: "ACTION_POP" });
  const Push = (value: T): void => dispatch({ type: "ACTION_PUSH", value });
  const Reset = (): void => dispatch({ type: "ACTION_RESET", initStack });
  const Length = (): number => stack.length;

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

useStack 関数は、v4ではstackの前の状態を利用した手続き的なコードでしたが、v5では ACTIONS_POP のように、前の状態を知らずにイベントを発火させるだけでよくなりました。またreducerも手続き的なコードを書く必要はなく、新たな状態を返すだけで目的を達成することができるようになります。

この書き方はReduxを書いていた方は慣れ親しんでいるかと思いますが、公式ドキュメントに書いてある通り基本的にはuseStateを利用しましょう。 useReducerは複数の値が関連する複雑な状態ロジックを持つ場合や次の状態が前の状態に依存する場合にだけ利用することをお勧めします。

v6 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>
  );
};

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

ただしこちらも必ず採用する必要があるわけではありません。適宜判断して採用を決めてください。

まとめ

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

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

これらによりViewとロジックが分離し、再利用性、可読性、テスタビリティが向上します。

このように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として洗練された良い実装が見つかるはずです。
また自分で実装する場合にもとても参考になります。

557
425
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
557
425