· 10分で読了

クラスコンポーネントからHooksへ

この記事は中国語から自動翻訳されたものです。翻訳によりニュアンスが失われている場合があります。

この記事では、各React HooksのAPIを網羅的に紹介するのではなく、設計の観点からその背後にある理由を探っていく。主に以下のセクションに分けて進める:

  • 関数コンポーネントとクラスコンポーネントの違い
  • コンポーネント間で似たロジックを再利用する方法
  • Higher-Order Component、Render Props、Mixinについて

関数コンポーネントとクラスコンポーネント

Reactの関数コンポーネントとクラスコンポーネントの違いはどこにあるのだろうか?機能面から見ると、いくつかの点に整理できる:

  1. 関数コンポーネントには state がない(Hookを使用しない場合)
  2. 関数コンポーネントには this がない(これは非常に重要だ)
  3. クラスコンポーネントはインスタンスメソッドを宣言できる

1. 関数コンポーネントには state がない(Hookを使用しない場合)

以前、関数コンポーネントを書く際は、props を渡すことでしかコンポーネントを操作できなかった。これ自体に何ら問題はなく、疑う余地もない。

テストもしやすい書き方だが、関数コンポーネントがクラスコンポーネントを完全に置き換えることはできなかった。その理由は、クラスコンポーネントは様々なライフサイクルメソッドを使用してコンポーネントをより細やかに制御でき、内部実装においても多くの柔軟性を持っていたからだ。また、state の仕組みによって、コンポーネント内部でより複雑なロジックを実現するための細かい操作が可能になっていた。

2. 関数コンポーネントには this がない

関数コンポーネントは関数であるため、this を持たない。言い換えると、this が指すオブジェクトがコンポーネント自身ではない。これにはいくつかの利点がある。一つは構文の簡潔さで、this.state.xxx のような冗長なコードを書く必要がなく、イベントハンドラーを渡す際にも利用シーンに応じて bind(this) する悩みがなくなることだ。

公式ドキュメントには「state を直接操作せず、必ず setState メソッドで状態を更新すること」と明確に書かれているが、実際の開発現場では、ドキュメントを読まずに自由奔放な発想で不適切な方法で state を操作するエンジニアが依然として存在する。例えば、以下のようなコードを直接書いてしまうケースだ:

this.state.verified = true;

これではReactの更新メカニズムをトリガーできず、他のエンジニアの誤解を招きやすい。コードの至る所にこのような記述があると、リファクタリングや改修の難易度が何倍にも跳ね上がる。関数コンポーネントなら、構文レベルでこの問題を根本から防ぐことができる。まあ、保守しにくいコードを書こうと思えばいくらでも方法は見つかってしまうものだが……。

特に注意すべきなのは、クラスコンポーネントでライフサイクルを使っていない場合、直接関数コンポーネントに変換できるものの、両者にはやはり違いがあるという点だ。最大の違いは this の扱いにある。

props と state はどちらもイミュータブル(immutable)だが、クラスコンポーネントを使用する場合、Reactの内部実装が this をコンポーネントインスタンスに向けるよう操作してくれる。つまり、this はミュータブル(可変)なのだ。

大半のケースでは問題にならないが、setTimeout や setInterval のような即時実行されないコードが絡むと、特に注意が必要になる:

function Profile({ userId }) {
  setTimeout(() => {
    fetchUserProfile(userId).then(alert);
  }, 2000);
  
  return ...
}

class Profile extends React.Component {
  componentDidUpdate() {
  	setTimeout(() => {
      getUserProfile(this.props.userId);
    }, 2000);
  }
  
  getProfile() {
    fetchUserProfile(this.props.userId);
  }
  
  render() {
    return ...
  }
}

この2秒の間に userId が変更されたらどうなるだろうか?

気づいただろうか?クラスコンポーネントは this が変化するため、この2秒の間に userId が変わると、クラスコンポーネントはそのメソッドが呼び出された時点の props を参照してしまう。一方で、関数コンポーネントはボタンをクリックした時点の props を使用する。

this.props を使用していると、実行される頃にはすでにコンテキストが変わってしまい、正しい結果を得られなくなる。変数を宣言して this.props を退避させておくこともできるが、根本的な解決にはならない。もし setTimeout の実装内でさらに props を呼び出す他のメソッドがあったらどうするのだろうか?

対照的に、関数コンポーネントは this が存在しないため、僕たちは安心して setTimeout や setInterval を利用できる。

一方で、クラスである以上、インスタンスメソッドを実装して外部にいくつかの操作用メソッドを公開することができる。

これは必ずしも良いこととは言えない(むしろ多くの場合は好ましくない)。後からそのメソッドを修正したりリネームしたりしたくなった場合、他の場所やコンポーネントから直接呼び出されていないかを考慮する必要があり、コンポーネントの変更やリファクタリングをより厄介なものにしてしまう。

初期設計の段階でそのメソッドの汎用性を保証できない限り、後々のリファクタリングでより大きな手間を費やすことになりかねない。

また、クラスコンポーネントでは似たようなロジックを持つコードを共通化するのが非常に難しい。

例えば、ユーザーのログイン状態に基づいて表示内容を切り替えたいとしよう。もし isLoggedIn の状態に応じて表示を行うすべてのコンポーネントが、直接その状態にアクセスできるようにしたい場合、一般的にはどのように設計するだろうか?

最初のアプローチとして、isLoggedIn のロジックと実装を Context に入れ、Context にアクセスしたいコンポーネントをその都度 Consumer でラップする方法が考えられる。

しかし、これでは実装全体が冗長になり、コンポーネント自体の実装とは直接関係のないコード(Consumerの追加など)が増えてしまう。

おおよそ以下のようになる:

const {Provider, Consumer} = createContext(null);

export default class UserContext extends React.Component {
  state = {
    isLoggedIn: false,
  };

  componentDidMount() {
    fetchUser()
    	.then(res => this.setState({
  			isLoggedIn: true    
      }))
  }

  render() {
    <Provider value={this.state}>
			{this.props.children}
    </Provider> 
  }
}

export Consumer;

App.js

const App = () => <UserContext>
  <Profile />
</UserContext>

Profile.js

import { Consumer } from 'UserContext';
class Profile extends React.Component {
  render() {
    <Consumer>
    	{({ isLoggedIn }) => isLoggedIn ? showProfile() : null}  
    </Consumer>
  }
}

シンプルさと再利用性を維持するため、当時人気を集めていた解決策には Higher-Order Component(高階コンポーネント)や Render Props、そしてさらに古くは Mixin があった。

Mixin

まず Mixin についてだが、Mixin が非推奨になった理由は極めてシンプルで、コンポーネントの内部実装に影響を与えやすく、変更が困難だからだ。コンポーネントが Mixin 内で定義されたメソッドに依存してしまい、後からの改修が非常に難しくなったり、ある Mixin がさらに別の Mixin に依存していたりすることがあった。詳しくはこちらの記事を参照してほしい。

もちろん、これは Mixin 自体が悪いパターンだという意味ではない。ただ、React においてはあまり適していなかったということだ。

High-order component

Higher-Order Component(高階コンポーネント)は、React コンポーネントを引数として受け取り、ラップされた新しいコンポーネントを返す関数を定義する手法だ。

最も代表的なユースケースは、Redux の connect だろう:

const MyProfile = ({ profile }) => {
};

export default connect(state => ({
  profile: state.profile,
}))(MyProfile);

この手法を使うと、関数内で引数を柔軟に定義でき、実装全体をよりエレガントにできる上、コンポーネント内部の実装を変更する必要もない。唯一の依存関係は props の受け取りにあるが、mapStateToProps を通じて props をコンポーネントが必要とする形に簡単に変換できる。

この関数は React コンポーネントを返す。通常の実装は次のようになる:

const withWindowSize = (WrappedComponent) => class WindowComponent extends React.Component {
  state = {
    screenSize: window.innerWidth,
  }

  setScreenSize = () => this.setState({ screenSize: window.innerWidth });

  componentDidMount() {
    window.addEventListener('resize', this.setScreenSize);
  }

  componentWillUnmount() {
    window.removeEventListener('resize', this.setScreenSize);
  }

  render() {
    return <WrappedComponent {...this.props} windowSize={this.state.screenSize}  />
  }
}

しかし、この手法でも多少なりともその背後にある実装を理解しておく必要がある。

例えばこのようなラッパーでは、「なるほど、withWindowSize が自動的に windowSize という props を追加してくれるのか」と裏側の挙動を把握していなければならない。

一見エレガントに見えるが、実際には関数内で工夫が必要であり、関数内で別途コンポーネントを定義したり、displayName を設定したりする必要がある。

また、他の関数と組み合わせて使おうとすると、記述が非常に冗長になってしまう。compose を使って簡略化できるとはいえ、やはり使い勝手には不便さが残る。

withWindowSize(withScroll(withRouter(connect(...

これは当時としてはかなり優れた解決策だったが、初学者にとっては決して小さくない学習コストとなっていた。

Render Props

Render Props は、引数を関数の引数として children に公開することで、どのような引数が渡されているのかを明確にし、それを利用するかどうかを自由に選択できるようにするパターンだ。

React の Context Consumer も Render Props の方式で値を取得している。

const ListContainer = ({ list }) => (
  <InfiniteList>
    {this.props.children(list)}
  </InfiniteList>
)

Render Props にもいくつかのデメリットがある。コンポーネントの内部を見てどのような引数が渡されているのかを確認しなければならず、相手側が関数ではなく通常のコンポーネントを渡してきた場合の対応がなされているかも考慮しなければならない。

これらの解決策はいずれも優れていたが、どこか帯に短し襷に長しという感があった。そのため、react-hooks の概念と実装が登場した際、React コミュニティですぐさま熱狂的な反響を呼んだのだ。

Hooks のコンセプトは、関数コンポーネントにあった本来の制約を取り払うことにある。関数コンポーネントの中で state を使ったり、副作用(side-effect)を宣言したり、ref や context などを扱えるようになり、かつてクラスコンポーネントでしか使えなかった機能のすべてを関数コンポーネントで代替できるようになった。

これにより、開発者はより軽量かつ簡潔にコードを構成できるようになり、Hook 自体も単なる関数であるため、ロジックを独自にカプセル化(カスタムHook化)することもできる。詳しくは Dan Abramov の書いた Making sense of react hooks を参照してほしい。

ここで、最も基本的な useState と useEffect の例を挙げておく:

function useWindowSize() {
  const [windowSize, setWindowSize] = useState(window.innerWidth);
  
  useEffect(() => {
    const setSize = () => {
      setWindowSize(window.innerWidth);
    };

    window.addEventListener('resize', setSize);

    return () => window.removeListener('resize', setSize);
  }, []);
  
  return windowSize;
}

コンポーネント内では、直接 useWindowSize を呼び出すだけで現在の window.innerWidth を取得できる。

const Layout = () => {
  const windowSize = useWindowSize();
  
  return ...
};

windowSize の出どころが分かりやすくなっただけでなく、実装も非常に直感的で、Higher-Order Component や Render Props のように余計なケースを考慮する必要がない。

もっとも、Hooks のすべてが完璧というわけではない。正しいレンダー結果を得るためには Hooks の呼び出し順序を一貫させなければならず、その保証のために新たに eslint-plugin-react-hooks が導入された。

また、Hooks では現時点でもクラスコンポーネントを完全に代替できない部分がある。componentDidCatch や getSnapshotBeforeUpdate といったメソッドがその例だ。また、Render Props を使用している際に親階層が DOM などの構造でラップされている場合、Hook だけで解決することはできない。

さらに、関数コンポーネントは依然として外部にメソッドを直接公開することができず、props を通じた制御しか行えないため、時としてそこまで便利ではない場面もある。

結論

React コミュニティが十分に巨大だったからこそ、コアチームはこうした機能の開発に専念できたのだと思う。そして、React Mixin や Higher-Order Component といった様々なアプローチを試行錯誤してきた歴史があったからこそ、Hooks という解決策にたどり着けたのかもしれない。

次々と新しい解決策が登場しているように見えても、その背後にある原理は似通っている。いずれも特定の問題を解決するために生み出された産物なのだ。

関連記事

他のトピックを探索