🏃‍♂️

React カスタムフックの使い方を頑張って解説する

2020/09/27に公開

はじめに

Reactのカスタムフックを勉強しようと思って調べていたところ、とても分かりやすい記事に出会いました。
https://qiita.com/sonatard/items/617f324228f75b9c802f

上記の記事を元にハンズオンで勉強してみた過程でかなり勉強になり、せっかくなので記事にまとめてみました。元記事はv1〜v6の構成でまとめられていますが、本記事ではv1〜v4までの主にカスタムフックの部分にフォーカスして、元記事を出来る限り噛み砕き、補足も入れながら説明しているような内容になっています。

自分と同じようにReactやカスタムフックスの理解がまだまだ、元記事を読んで見たけど理解に及ばなかったというレベルの人にとっては、何か理解の助けになるかもしれないです。

※元記事との整合性を保つため題材は同様の内容にしています

作るもの

先に今回作るものを確認しておきます。
下記のような見た目の何の変哲もないページネーションを作ります。

機能としては、

  • ページの最小値、最大値を設定して、その範囲でページ移動
  • ページネーションの操作の履歴を保存してundoできる
  • 履歴の消去(リセット)

それでは、元記事の内容に沿って進めていきます。

v1 カスタムフック未使用

v1 全体のコード

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

それでは早速、元となるv1のコードを見てみます。

Pageコンポーネントが返しているのは、上記の画像のような、現在のページと各ボタンのview(見た目)です。

ただ、このPageコンポーネントの中には各ボタンをクリックしたときに実行されるロジックも含まれています。これがいわゆる「viewとロジックが混在している状態」です。

Reactの役割をあえて一言で表現すると「画面上の見た目いわゆるviewを提供」することです。

もちろん、viewを生成するために上記のように必要なデータや制御があるのは当然です。ただし、viewと混在させるのは好ましくないです。

更にこのロジックの中には、履歴を記録する処理、移動可能なページ遷移を制御する処理等、異なる役割の処理が混在しています。

一旦、v1の問題点と影響を整理すると、

  • view(見た目)とロジックが混在している
    • 単純に読みづらい
    • テストがしにくい(自分はテストを全然やってきてないのでそこまでダメージに感じてないが、いざテストするときを考えるとやりにくそう)
  • 複数の役割のロジックが混在している(履歴データの記録・操作をする処理、移動可能なページ遷移を制御する処理等)
    • 単純に読みづらい
    • ロジックの再利用性が低くなる

他にもありそうですが、上記でも十分イケてないことが分かります。

v2 カスタムフック

v2 全体のコード

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];
};

v2ではPageコンポーネントからロジック部分を抜き出します。ここで登場するのが「カスタムフック」です。

コードを見る前にフックについて少々前置きします。

そもそもフックとは?ということを一から説明することは割愛しますが、公式ドキュメントには下記のようにあります。

要するにフックとは?
フックとは、関数コンポーネントに state やライフサイクルといった React の機能を “接続する (hook into)” ための関数です。フックは React をクラスなしに使うための機能ですので、クラス内では機能しません。
https://ja.reactjs.org/docs/hooks-overview.html#but-what-is-a-hook

Reactには、classコンポーネントとfunctionコンポーネントがありますが、元々はclassコンポーネントでしか出来ないことが多くありましたが、記述の冗長性等、問題点も多くありました。

フックの登場でfunctionコンポーネントでもclassコンポーネントと同様の機能が使えるようになったという経緯もあり、フックの価値は「classコンポーネント無しで使えるようになったこと」とよく言及されています。

もちろん上記もメリットですが、実はもっと重要な価値があります。それは「ロジックを再利用できるようになったこと」です。

stateを定義するためのuseStateも、副作用のある処理をするときに使えるuseEffectも再利用が可能になっています(もちろん記述がスマートになったメリットもありますが)。

そして公式に用意されたようなReactの機能を利用するためのフックだけでなく、「Reactの機能 + 独自のロジック」を再利用可能にするフックが「カスタムフック」です。

公式ドキュメントでは下記のように説明されています。

カスタムフックはstateを使うロジック(データの購読を登録したり現在の値を覚えておいたり)を共有するためのもの
https://ja.reactjs.org/docs/hooks-custom.html

前置きが長くなりましたが、v2のコードを見てみます。

まずは元々v1にもあったPage.tsxでは、viewに関わる記述は以下のようになりました。

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

この部分だけ見ると、カスタムフックがどうこうという話ではなく、onClick内の処理を関数として呼び出すことで、viewに関する記述が簡略してます。ロジックが抜き出されるだけで、HTMLが提供するべき文書構造が見えやすくなっています。これだけでもかなり見やすくなりますよね。

では肝心のカスタムフック(useLocalHistory)の内容を一つずつ確認していきます。

まず引数は、topPagelastPageの2つを取ります。ページネーションの最初のページと最後のページを指定するためのものです。

そして、関数として最終的にreturnするものは下記です。

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

大きく分けると、現在のページを表すcurrentPageと、各onClick時に実行する関数であるTop, Next, Back, Last, Resetに分けられます。

つまりこのカスタムフックを言語化すると、
「ページ範囲を受け取り、現在のページとページ操作の方法を提供する」ものと言えそうです。

また、今回のカスタムフックにも下記のようにhistoryのstateが定義されています。

const [history, setHistory] = useState<number[]>(initHistory);

これはまさにReactの機能であり、これに加えて独自のロジックが含まれていて、前述した「Reactの機能 + 独自のロジック」で構成されているカスタムフックということになり、任意の場所から呼び出せる、再利用可能なものになっています。

ひとまず、これでPage.tsxには直接ロジックは存在しない状態になりました。

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

v3 全体のコード

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 }];
};

インターフェースはReactやカスタムフックではなく、TypeScriptの機能になりますが、自分的にも学びがありカスタムフックを利用するにあたっても重要な概念だと思うのでそのまま取り上げます。

まず、インターフェースとは何か。

少し調べてみたのですが意外と明確に上手く定義している説明が見当たらず、自分の言葉での説明になりますが「メンバーの種類、型、必須項目を定義する機能」と解釈しています。

ユースケースとしては、例えば、特定の関数を定義するときの引数や戻り値の種類、型、必須項目の指定等です。

実際にコードを見るのが分かりやすいと思うので引き続き進めていきます。今回のコードでインターフェースを定義している部分は以下です。

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

LocalHistoryというインターフェースにTop, Next, Back, Last, Resetの5つのメンバーが含まれています。また全て必須メンバーとなります(任意の場合は?を付けて表現します 例:Top?: 〜等)

更に今回はたまたま全ての型が同じ() => voidに設定されています。これは、「引数なしで戻り値としてvoid型」を返すことを表しています。void型とは「何も返さない」を意味する型です。

実際に定義したインターフェースを利用している箇所を確認します。一部抜き出すのは難しいのでuseLocalHistoryを丸ごと確認します。

export const useLocalHistory = (
  topPage: number,
  lastPage: number
): [number, LocalHistory] => {

  // 関数の内容

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

まず引数として、topPagelastPageを取っていますが、いずれもnumberが型として指定されています。これでuseLocalHistoryは2つのnumber型しか受け付けません。

その後、: [number, LocalHistory]とありますが、これが戻り値の型指定となります。number型とLocalHistory型の2つの要素を含む配列が戻り値になることを表しています。LocalHistory型は先程インターフェースで定義した型です。

実際に戻り値部分を見てみると、

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

2つの要素を含んだ配列で、currentPage{ Top, Next, Back, Last, Reset }となっています。

currentPageがnumber型で、{ Top, Next, Back, Last, Reset }LocalHistoryインターフェースで定義した5つのメンバーが定義されています。

少し逸れてましたが本筋の解説に戻ります。

上記でインターフェースを定義して型定義の部分は分かりやすくなりましたが、もう一つ着眼するべきは、元々Top, Next, Back, Last, Resetをバラバラに受け渡していたのを、{ Top, Next, Back, Last, Reset }と一つのオブジェクトにまとめた点です。

こうすることで受け渡しもスッキリするし、更にインターフェースを定義してLocalHistoryという名前が付けられているので、より明確に操作系のメソッドの集約化できています。

結果、Pageコンポーネントからの呼び出しは下記のようになります。

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

historyで操作系のメソッド群を受け取り、history.Topの様に、historyのメソッドとしてTop等を実行しています。

これで、コードリーディング時に「history.で実行 = currentPage等を操作している」ということが読み取れるようになります。また、前述した通り受け渡しも簡単になりました。

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

v4 全体のコード

Page.tsx
v3と同様なので割愛

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 }];
};

読み疲れたと思いますがもう少し頑張りましょう(自分も書き疲れました)

v4では先のuseLocalHistoryからロジックを抜き出す形で、useStackというカスタムフックをもう一つ作っています。

先述した通り、今回のコードのロジックは、履歴データの記録・操作をする処理、移動可能なページ遷移を制御する処理等が混在しています。

この内、履歴データの記録・操作をする処理の部分をuseStackとして抜き出すというわけです。

正直、自分の理解ではどこまで細分化する必要があるのか分かっていないのですが、元記事の言葉を借りると「一般的なデータ構造をロジックから分離 委譲(delegation)」という部分に該当するんだと思います。「再利用性、可読性、テスタビリティが向上」という効果は確かにその通りですね。

履歴データの記録・操作は基本的なデータ構造の一つであるスタックが利用されています。スタックはLIFO(Last-In-First-Out)、最後に入れたものを最初に取り出すデータ構造です。ちなみに、本を積み上げたときの取り出し操作と同じなのでstack(積み重ねる)と呼ぶらしいです。

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 }];
};

Popはデータ削除、Pushはデータ追加、Resetはデータの初期化、Lengthは操作ではないですが、データの数の確認となります。

元のuseLocalHistoryを見ていきます。

まずは、useStackの読み込みは下記のようになっています。

const [currentPage, stack] = useStack<number>(initHistory);

initHistoryで初期値の配列を引数として渡し、現在ページのcurrentPageと先のスタックの操作系のメソッド群のstackを受け取ります。

こうすることで、useLocalHistoryの中にはデータ操作のロジックが無くなりました。先程問題点として挙げた「履歴データの記録・操作をする処理、移動可能なページ遷移を制御する処理等が混在」が解消されました。

  • useStack:履歴データの記録・操作をする処理
  • useLocalHistory:移動可能なページ遷移を制御する処理

これで、無事ロジックも分離されました。

まとめ

本記事で説明するのはここまでです。

元記事では、v5, v6 としてuseReducerの説明や、Container, Presentationalコンポーネントの分離がありますので興味のある人は読んでみてください。

自分の理解では「カスタムフックはロジックを抽出するやつ」のようなノリでふわっと理解していました。

「ロジックを抽出する」はあくまで手段で、目的はあえてヒトコトで言うと「コンポーネントの再利用性を高めること」と感じています。

結局やることは変わらない部分もありますが、不思議なもので意識が変わります。

「ロジックを共通化しよう」じゃなく、「コンポーネントの再利用性」を高めるためにどうすれば良いかを考えるようになり、その結果、例えばコードを書く前に必要な機能を洗い出す、どんな単位で処理を実装するか、コンポーネントを分けるかを計画する等、行動が変わります。

個人的には元記事をハンズオンしてみた一番の収穫が、この思考プロセスの変化でした。

また、元記事では最後の項で、抽象化した言葉で今回のコード遍歴がまとめられています。

正直、クズエンジニアの自分はまだ全てを理解できてない状態ですが、この部分がプログラミング的には本質の考え方だと思うので、まだ読んでいない人は是非読んでみてください。
https://qiita.com/sonatard/items/617f324228f75b9c802f#まとめ

Discussion