1. ホーム
  2. React

【React】HOC(高階コンポーネント)の使い方|複数のコンポーネントで共通ロジックを再利用する

Share

複数のコンポーネントに、まったく同じ処理を何度も書いてしまっていませんか。ローディング中のスピナー表示や、ログイン状態のチェックなどは、どのコンポーネントでも似たようなコードになりがちです。こうした共通のロジックを1か所にまとめて再利用する古くからの手法が、この記事で解説する HOC(Higher-Order Component=高階コンポーネント)です。この記事では、HOC とは何かという基本から、withLoadingwithAuth といった実用的な例、つまずきやすいポイント、そして現在のカスタムフックとの使い分けまでを、React 関数コンポーネント(TSX)のコード付きで解説します。

HOC(高階コンポーネント)とは何か

HOC(Higher-Order Component)とは、コンポーネントを引数として受け取り、機能を追加した新しいコンポーネントを返す関数のことです。ここで大切なのは、HOC は React が用意した特別な API ではなく、あくまで関数を使った設計パターン(デザインパターン)の1つだという点です。React 本体に createHOC のような専用の仕組みがあるわけではなく、ただの JavaScript の関数として自分で書きます。

名前が難しそうに見えますが、形はとてもシンプルです。「コンポーネントを受け取って、コンポーネントを返す」という骨組みだけを取り出すと、次のようになります。

withX.tsx
// Component を受け取り、新しいコンポーネントを返す関数
const withX = (Component) => (props) => {
  // ここで何か機能を追加する
  return <Component {...props} />;
};

この withX に何らかのコンポーネントを渡すと、「元の見た目や機能はそのままに、追加の処理が乗った別のコンポーネント」が返ってきます。この「関数でコンポーネントを包む(ラップする)」という発想が HOC のすべてです。名前の「高階(Higher-Order)」は、関数を受け取ったり返したりする高階関数(Higher-Order Function)に由来しています。

なぜ HOC を使うのか

HOC を使う目的は、複数のコンポーネントにまたがる共通の処理を1か所にまとめて再利用することです。たとえば「データを読み込んでいる間はスピナーを出す」「ログインしていないユーザーには中身を見せない」「表示のたびにログを記録する」といった処理は、いろいろな画面で必要になります。これを画面ごとにコピーして書くと、修正するときにすべての箇所を直さなければならず、抜け漏れやバグの温床になります。

こうした共通ロジックを HOC として1つ切り出しておけば、どのコンポーネントにも withLoading(SomeComponent) のように後付けで機能を足せます。ロジック本体は HOC の中だけにあるので、仕様が変わっても直す場所は1か所で済みます。関心事(共通処理)と、見た目を担当する個々のコンポーネントを分離できる点が HOC の利点です。

基本例:withLoading でローディング表示を共通化する

もっとも分かりやすい例として、withLoading という HOC を作ってみます。これは「isLoading という prop が true の間はスピナーを表示し、false になったら元のコンポーネントを描画する」という機能を追加する HOC です。まずコード全体を見てみましょう。

withLoading.tsx
import { ComponentType } from 'react';

// isLoading を追加で受け取れるようにする型
type WithLoadingProps = {
  isLoading: boolean;
};

// Component を受け取り、ローディング機能を足したコンポーネントを返す
function withLoading<P>(Component: ComponentType<P>) {
  // 返すのは「新しいコンポーネント」
  return function WithLoading(props: P & WithLoadingProps) {
    const { isLoading, ...rest } = props;

    // 読み込み中はスピナーを表示する
    if (isLoading) {
      return <p>読み込み中...</p>;
    }

    // それ以外は元のコンポーネントをそのまま描画する
    return <Component {...(rest as P)} />;
  };
}

export default withLoading;

この HOC は、isLoading を自分で受け取って処理し、それ以外の props(rest)は元のコンポーネントへそのまま渡しています。使う側では、表示したいコンポーネントを withLoading() で包むだけです。

UserList.tsx
import withLoading from './withLoading';

type User = { id: number; name: string };

// 元になる、純粋に「一覧を表示するだけ」のコンポーネント
function UserList({ users }: { users: User[] }) {
  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

// withLoading で包み、ローディング機能を追加する
export default withLoading(UserList);

こうしてできた withLoading(UserList) は、isLoadingusers の両方を受け取れるコンポーネントになります。<UserListWithLoading isLoading={true} users={[]} /> のように使えば、読み込み中はスピナー、完了後は一覧が表示されます。UserList 自身はローディングのことを何も知らないまま、機能だけを外側から足せているのがポイントです。同じ withLoading を他の一覧コンポーネントにも使い回せます。

props を透過的に渡すことが重要

HOC を書くうえで見落としてはいけないのが、元のコンポーネントに必要な props を、そのまま素通しで渡すことです。先ほどのコードでいえば <Component {...rest} /> の部分がそれにあたります。HOC は元のコンポーネントを包んでいるので、外から渡された props はいったん HOC が受け取ります。このとき HOC が使う props(isLoading)だけを取り出し、残りはすべて元のコンポーネントに流してあげる必要があります。

もしこの {...rest} を書き忘れると、元のコンポーネントが本来受け取るはずだった users などの props が届かず、正しく動かなくなります。HOC はあくまで「機能を足すだけ」の存在であって、元のコンポーネントの入力を邪魔してはいけません。自分が処理する props 以外はすべてスプレッド構文({...props})で透過的に渡す、これが HOC を正しく作るための基本ルールです。

命名規約と displayName を設定する

HOC には慣習的な決まりがいくつかあり、それに従っておくとコードが読みやすくなり、デバッグもしやすくなります。

関数名は withXxx にする

HOC の名前は、with で始めるのが React コミュニティの一般的な命名規約です。withLoadingwithAuthwithLogger のように、「〜という機能を付けて(with)返す関数」であることが名前から一目で分かるようにします。これは強制ではありませんが、慣習に合わせておくと、他の開発者がコードを読んだときに「これは HOC だな」とすぐ理解できます。

displayName でラップ後の表示名を分かりやすくする

HOC でコンポーネントを包むと、React DevTools(ブラウザの開発者ツール)上での表示名が分かりにくくなることがあります。何もしないと WithLoading のような HOC 側の名前だけが並んでしまい、「どのコンポーネントを包んだものか」が追いにくくなります。そこで、返すコンポーネントに displayName を設定し、元のコンポーネント名を含めておくと、DevTools のツリーが読みやすくなります。

withLoading.tsx
import { ComponentType } from 'react';

function withLoading<P>(Component: ComponentType<P>) {
  function WithLoading(props: P & { isLoading: boolean }) {
    const { isLoading, ...rest } = props;
    if (isLoading) return <p>読み込み中...</p>;
    return <Component {...(rest as P)} />;
  }

  // 元のコンポーネント名を取り出す(無ければ 'Component')
  const name = Component.displayName || Component.name || 'Component';

  // DevTools に「WithLoading(UserList)」のように表示される
  WithLoading.displayName = `WithLoading(${name})`;

  return WithLoading;
}

export default withLoading;

このように WithLoading(UserList) という形の名前を付けておくと、DevTools 上でも「UserList を withLoading で包んだもの」だと一目で分かります。小さな工夫ですが、HOC を何段も重ねたときにデバッグが格段に楽になります。

実用例:withAuth で認証チェックを共通化する

もう1つ、実務でよく使われる例として認証チェックの HOC を作ってみます。withAuth は「ログインしていればコンポーネントを表示し、していなければログイン画面へ誘導する」という機能を追加します。会員ページやダッシュボードなど、ログインが前提の画面すべてに同じチェックを書かずに済みます。

withAuth.tsx
import { ComponentType } from 'react';
import { useAuth } from './useAuth'; // ログイン状態を返す想定のフック

function withAuth<P>(Component: ComponentType<P>) {
  function WithAuth(props: P) {
    const { user } = useAuth();

    // 未ログインならログイン画面を表示する
    if (!user) {
      return <LoginScreen />;
    }

    // ログイン済みなら元のコンポーネントを描画する
    return <Component {...props} />;
  }

  const name = Component.displayName || Component.name || 'Component';
  WithAuth.displayName = `WithAuth(${name})`;

  return WithAuth;
}

export default withAuth;

使うときは export default withAuth(Dashboard); のように包むだけです。これで Dashboard は「ログインしていないと見られないコンポーネント」に早変わりします。認証のロジックは withAuth の中に集約されているので、仕様が変わっても直すのはこの HOC だけです。このように、条件によって表示する内容を丸ごと差し替えたい場合に HOC は力を発揮します。

render の中で HOC を呼び出してはいけない

HOC でとくに注意したいのが、HOC の適用(コンポーネントを包む処理)は、必ずコンポーネントの外側で一度だけ行うという点です。次のように、レンダリングされる関数の中(JSX を組み立てる過程)で HOC を呼び出してはいけません。

BadExample.tsx
function Page(props: PageProps) {
  // NG: レンダリングのたびに withLoading が実行され、
  //     毎回「別物」の新しいコンポーネント型が作られてしまう
  const UserListWithLoading = withLoading(UserList);

  return <UserListWithLoading {...props} />;
}

これがなぜ問題なのかというと、Page が再レンダリングされるたびに withLoading(UserList) が呼ばれ、そのたびに「まったく新しいコンポーネント型」が生成されるからです。React は「前回と同じコンポーネントかどうか」を型(正体)で見分けています。毎回別物の型に入れ替わると、React はそれを「前のコンポーネントを捨てて、新しいコンポーネントを作り直した」とみなします。結果として、その中で保持していた state が毎回リセットされ、入力途中の値が消えるなどの不具合が起きます。DOM も作り直されるため、パフォーマンスの面でも無駄が生じます。

正しくは、次のようにコンポーネントの外(モジュールのトップレベル)で一度だけ HOC を適用し、その結果を使い回します。こうすればコンポーネント型は常に同じものになり、state も保持されます。

GoodExample.tsx
// OK: コンポーネントの外で一度だけ適用する
const UserListWithLoading = withLoading(UserList);

function Page(props: PageProps) {
  // ここでは、できあがった同じコンポーネントを使うだけ
  return <UserListWithLoading {...props} />;
}

今はカスタムフックとの使い分けが大切

最後に公平な視点として、現在の React 開発では HOC がかつてほど多用されなくなっている点にも触れておきます。React 16.8 でフック(Hooks)が導入されて以降、共通ロジックの再利用はカスタムフックで実現するのが主流になりました。ローディング状態やログイン状態の管理といったロジックの共有だけが目的なら、useLoadinguseAuth のようなカスタムフックのほうが素直です。

HOC には、コンポーネントを何段も包むと構造が深くネストして分かりにくくなる(ラッパー地獄と呼ばれます)、props がどこから来ているか追いにくい、といった弱点があります。カスタムフックはこうした問題が起きにくく、ロジックの共有という目的に対してより見通しよく書けます。両者の性質を整理すると次のようになります。

手法得意なこと
HOC表示する JSX を条件で丸ごと差し替える(認証ガードなど)。既存ライブラリが HOC 形式の API を提供している場合。
カスタムフックstate やロジックだけを複数コンポーネントで共有する。ネストを増やさず見通しよく書ける。

とはいえ HOC が過去のものになったわけではありません。認証チェックのように条件によって描画する内容そのものを差し替えたいケースや、react-reduxconnect のように既存ライブラリが HOC 形式で機能を提供している場面では、今も HOC が自然な選択肢です。「ロジックだけの共有ならカスタムフック、表示の丸ごと差し替えなら HOC」と役割で使い分けるとよいでしょう。

まとめ

HOC(高階コンポーネント)は、コンポーネントを受け取って機能を足した新しいコンポーネントを返す関数で、React の特別な API ではなく設計パターンの1つです。ローディング表示や認証チェックといった共通ロジックを1か所にまとめ、複数のコンポーネントで再利用できます。作るときは、自分が使う props 以外を {...props} で透過的に渡すこと、名前は withXxx にして displayName を設定すること、そしてレンダリングの中で HOC を呼ばず必ずコンポーネントの外で一度だけ適用することが大切です。現在はロジックの共有ならカスタムフックが第一候補ですが、表示を条件で丸ごと差し替えたい場面や既存ライブラリの API では、今も HOC が有効な選択肢です。目的に応じて使い分けてください。

参考ページ