Programming Field

[Web] ReactでのHOCとHooksの使い分け 〜 Hooksだけではうまく表現できないケース

React(ReactJS)において、React 16.8 で導入された「Hooks」(Hook, フック)の機能は非常に便利であり、従来HOC(High-Order Component; 高階コンポーネント)などで記述していた機能の多くがより分かりやすい形に書き換えられるようになっています。

既にHooksが出てから時が経っているため、検索するとHooksの使い方がたくさんヒットすると思います。ここでは詳細な書き方の説明は省略し、Hooksの恩恵が受けられる「Hooksが活きそうな場面」と、わずかながら存在する「Hooksでは表現できないHOC」を紹介します。

Hooksが活きそうな場面

Hooksを使うことにより、従来クラスコンポーネントで管理していた「状態」(state)は関数コンポーネントでも扱えるようになります。例として、以下のように状態を定義しつつ、状態による出し分けを書くことができます。

import * as React from 'react';
import { useState } from 'react';

const MyComponent = () => {
  const [visible, setVisible] = useState(false);

  return (
    <>
      <p>
        <button onClick={() => setVisible(true)}>Show Me</button>
      </p>
      {visible && <p>Visible!</p>}
    </>
  );
};

Hooksはまた「カスタムフック」とも呼ばれる、いくつかのHooks系関数をラップした新たな関数を作ることで、「Contextから特定の値を取り出すHooks (useContextをラップ)」「『特定の条件で変わる値』を返すHooks関数 (useStateなどをラップ)」を作ることができます。例えば、以下の「useIsHandheldView」は特定の画面幅以下かどうかを返すカスタムフック関数であり、条件が変わると戻り値が変わるため、この関数を使うことでコンポーネントはその条件に応じた出し分けを行うことができます。(また、更新が必要かどうかが自動的に判定されます。)

import { useEffect, useState } from 'react';

// モバイル用の画面(特定の画面幅以下)かどうかを返す
function useIsHandheldView() {
  const [isHandheldView, setIsHandheldView] = useState(false);
  useEffect(() => {
    const updater = () => {
      setIsHandheldView(window.innerWidth <= 640);
    };
    // resize イベントを見て更新
    window.addEventListener('resize', updater, false);
    // 初期状態を使って更新
    updater();
    // アンマウントされたときにはイベントハンドルを解除
    return () => {
      window.removeEventListener('resize', updater, false);
    };
  }, []);
  return isHandheldView;
}

これらにより、Hooksは従来HOCで行われていたような「コンポーネントに特定の値を注入する」のを容易にしています。例えば上記のuseIsHandheldViewの例をHOCで書くとすると、

import * as React from 'react';

// モバイル用の画面(特定の画面幅以下)かどうかを「isHandheldView」として
// 描画時に BaseComponent に渡すHOC
function withIsHandheldView(BaseComponent) {
  return class extends React.Component {
    constructor(props) {
      super(props);
      setState({ isHandheldView: false });
    }

    updater = () => {
      const newValue = window.innerWidth <= 640;
      if (this.state.isHandheldView !== newValue) {
        this.setState({ isHandheldView: newValue });
      }
    };

    componentDidMount() {
      // resize イベントを見て更新
      window.addEventListener('resize', this.updater, false);
      // 初期状態を使って更新
      this.updater();
    }

    componentWillUnmount() {
      // アンマウントされるときにはイベントハンドルを解除
      window.removeEventListener('resize', this.updater, false);
    }

    render() {
      const { props, state } = this;
      // もともとの props に isHandheldView (state にある) を増やす
      return <BaseComponent {...{...props, ...state}} />;
    }
  };
}

のようになります。

※ HOCの中でHooksを使うこともできるので、上記のHOCで記述しているクラスコンポーネントは関数コンポーネントとして以下のように書くこともできます。

import * as React from 'react';
import { useEffect, useState } from 'react';

// モバイル用の画面(特定の画面幅以下)かどうかを「isHandheldView」として
// 描画時に BaseComponent に渡すHOC
function withIsHandheldView(BaseComponent) {
  return (props) => {
    const [isHandheldView, setIsHandheldView] = useState(false);
    useEffect(() => {
      const updater = () => {
        setIsHandheldView(window.innerWidth <= 640);
      };
      // resize イベントを見て更新
      window.addEventListener('resize', updater, false);
      // 初期状態を使って更新
      updater();
      // アンマウントされたときにはイベントハンドルを解除
      return () => {
        window.removeEventListener('resize', updater, false);
      };
    }, []);
    return <BaseComponent {...{...props, isHandheldView}} />;
  };
}

HOCで実装する場合、実際に何のプロパティー(名前)が注入されるかが、HOCの説明(仕様)に記載されていない限り実装を見なければならないため分かりにくく、注入したいデータが増えるとHOCが多段となってデバッグツールなどで見づらくなるなど、いくつかの問題が存在しています(他にもありますが省略します)。そのため、今は特別な事情がない限りはHooksを使って実装していくのが望ましいという見方が大勢です。

Hooksでは表現できないHOC

しかしながら、ごく少数ながら「Hooksでは表現できない」実装があり、それはHOCなどで書く方が適している場合があります。

その例として「制限されている場合は固定表示にする」という機能を考えてみます。再利用を考えずにHooksで書く場合、

import * as React from 'react';
// 制限内容を表示できるかを返すカスタムフック
import useIsAllowed from './useIsAllowed';

const MyComponent = () => {
  const isAllowed = useIsAllowed();
  return (
    <>
      {isAllowed && <p>(何かしら権限を必要とするコンテンツテスト)</p>}
      {!isAllowed && <p>許可されていません。</p>}
    </>
  );
};

となります。ここで「再利用したい」と考えた場合、主に「描画する物を引数に取るカスタムフックを書く」あるいは「コンポジションを使う(コンポーネント化する)」といった方法が考えられます。前者は「useRestrictedView」として

import * as React from 'react';
// カスタムフック
import useIsAllowed from './useIsAllowed';

// 制限されている場合に固定表示の描画結果を返すHooks
// nodeToShowWhenAllowed: 制限されていない場合に表示する内容
function useRestrictedView(nodeToShowWhenAllowed) {
  const isAllowed = useIsAllowed();
  return (
    <>
      {isAllowed && nodeToShowWhenAllowed}
      {!isAllowed && <p>許可されていません。</p>}
    </>
  );
}

// 使い方の例

const MyRestrictedContents = () => {
  return <p>(何かしら権限を必要とするコンテンツテスト)</p>;
};

const MyComponent = () => useRestrictedView(
  <MyRestrictedContents />
);

などのように実装でき、後者(コンポジション)は「WithRestrictedView」として

// 制限されている場合に children ではなく固定表示を描画するコンポーネント
const WithRestrictedView = ({ children }) => {
  const isAllowed = useIsAllowed();
  return (
    <>
      {isAllowed && children}
      {!isAllowed && <p>許可されていません。</p>}
    </>
  );
};

// 使い方の例

const MyRestrictedContents = () => {
  return <p>(何かしら権限を必要とするコンテンツテスト)</p>;
};

const MyComponent = () => (
  <WithRestrictedView>
    <MyRestrictedContents />
  </WithRestrictedView>
);

などと実装できます。

しかし、上記で挙げた2つの方法は実は同じ問題があり、「表示する・しないにかかわらず、固定表示ではない方の描画が常に行われている」というものがあります。

※ 両者とも、受け取るデータ(前者は useRestrictedView の引数、後者は children)を「表示したいものを返す関数」として、条件を満たす場合のみその関数を呼び出すようにすることで、「条件によっては描画しない」を達成することは出来ます。ただ、利用者側から見ると都度関数が生成される、または(都度生成回避のため)別途関数を定義する必要がある、といったわずらわしさや、特にコンポジションの方法において「子要素として関数を指定する」という、(特別珍しいわけではないですが)やや特殊な記法が必要になるといった懸念があります。

一方、HOC(とHooks)で記述する場合は、

// 制限されている場合に固定表示の描画を行うHOC
function withRestrictedView(BaseComponent) {
  return (props) => {
    const isAllowed = useIsAllowed();
    return (
      <>
        {isAllowed && <BaseComponent {...props} />}
        {!isAllowed && <p>許可されていません。</p>}
      </>
    );
  };
}

// 使い方の例

const MyRestrictedContents = () => {
  return <p>(何かしら権限を必要とするコンテンツテスト)</p>;
};

const MyComponent = withRestrictedView(MyRestrictedContents);

と書くことができます。先に紹介した2例と異なり、こちらは「制限されている場合には固定表示ではない方の描画が行われない」ことを達成しつつも、それを利用する際にはHOCでの一般的な利用方法と特に変わらない形で利用できています。

以上から、この「描画そのものも制御したい」ようなパターンにおいては、HooksやコンポジションでやるよりもHOCを活用するのがベターではと考えています。

まとめ

  • 基本的にはHooksで記述する方が、記述しやすさや分かりやすさなどの面で向いています。
  • ただし、「描画する・しない」といった制御をしたい場合など、Hooksを使うと不都合があるパターンがあるため、HOCで書くことも頭の片隅に入れておくと良いと思います。