AQ Tech Blog

Reactのクラスコンポーネントと関数コンポーネントの違い ~ライフサイクルや状態管理の観点から~

作成者: yukinaru.fujihashi|2024年12月22日

本記事はアジアクエスト Advent Calendar 2024の記事です。

Reactのライフサイクルを考える

Reactにはクラスコンポーネントと関数コンポーネントがあります。
2019年にReact16.8でReact Hooksが導入されて以降、関数コンポーネントが主流となりはじめ、現在は公式からも推奨されています。
しかし、古いプロジェクトの保守や改修などでは今でもクラスコンポーネントを触ることもあるでしょう。
そこで今回はクラスコンポーネントと関数コンポーネントの違いについて、ライフサイクルや状態管理の観点から比較してみました。

クラスコンポーネント

クラスコンポーネントは、Reactの古典的な方法で、その名の通りJavaScriptのクラスを使った記述方法で、render()関数の中でUIを返すように記述します。
必要に応じてconstructor()関数の中でstateの定義やイベントハンドラをバインドします。
副作用(ざっくり言うとレンダリング以外のこと)はcomponentDidMount()をはじめとした各種ライフサイクルの中に記述します。

関数コンポーネント

関数コンポーネントは、コンポーネント自体を関数で定義してUIを直接返すことで、よりシンプルで簡潔な構文で記述できます。 クラスコンポーネントで使用していたstateや各種ライフサイクルは利用できませんが、React Hooksを使って同様の機能を実現できます。

ライフサイクルについて

クラスコンポーネントではライフサイクルメソッドが用意されており、それぞれ以下のような順序で実行されます。
(ここでは分かりやすさを優先し、一般的なライフサイクルのみ記載しています。)

  1. マウント
    • constructor()
    • render()
    • componentDidMount()
  2. 更新
    • render()
    • componentDidUpdate()
  3. アンマウント
    • componentWillUnmount()
  4. エラーハンドリング
    • static getDerivedStateFromError()
    • componentDidCatch()

参考:React Lifecycle Methods diagram

関数コンポーネントではライフサイクルは撤廃されています。
それぞれのライフサイクルメソッドに相当するものはどのように置き換わっているのか見ていきましょう。

マウント

一番最初にコンポーネントが初期化され、DOMが描画されるまでをマウントと呼びます。
クラスコンポーネントではconstructor()がマウントされる前に実行され、propsの継承とstateの初期化をしていました。
関数コンポーネントではconstructor()と全く同等のものは削除され存在しません。
propsは関数の引数として、stateはuseState()を用いて実装します。
マウント時に副作用が必要な処理はクラスコンポーネントではcomponentDidMount()で記述していましたが、関数コンポーネントではuseEffect()を用いて実装します。 
useEffect()はマウント時だけでなく、コンポーネントが更新されるたびに副作用を実行するためのHooksですが、第二引数の依存配列に空の配列[]を渡すことで一度だけ実行できます。

import { Component } from 'react';

class MyComponent extends Component {
constructor(props) {
super(props);
this.state = { count: 0 };
}

componentDidMount() {
// マウント時だけ変更する例としてタイトルを更新しています
document.title = `You clicked ${this.state.count} times`;
}

render() {
return (
<div>
<p>Count: {this.state.count}</p>
<button onClick={() => this.setState({ count: this.state.count + 1 })}>
Click me
</button>
</div>
);
}
}

export default MyComponent;
import { useState } from 'react';

const MyComponent = (props) => {
const [count, setCount] = useState(0);
useEffect(() => {
// マウント時だけ変更する例なので、第二引数には空の配列を渡しています。
document.title = `You clicked ${count} times`;
},[]);

return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
</div>
);
};

export default MyComponent;

更新

propsやstateが変更された時に、DOMが再描画されることを更新と呼びます。
クラスコンポーネントではUIをrender()関数の中で返していますが、関数コンポーネントでは関数の返り値としてUIを定義します。つまり、関数コンポーネントはクラスコンポーネントのrender()関数を拡張したものととらえることができます。
クラスコンポーネントでは副作用が必要な処理はcomponentDidUpdate()を使用します。
このとき、引数に更新前の状態であるprevPropsおよびprevStateを渡すことができます。これを使うことで更新前の状態と比較して変化したときのみ実行する処理の実装が可能です。
関数コンポーネントでは副作用が必要な処理はuseEffect()を使用します。
このとき、第二引数に依存配列を指定することで、配列に記述したそれぞれの値が変化したときのみ実行できます。
初期レンダリング時に大きくレイアウトが変わるようなDOMの改変など、ブラウザの画面描画をブロックして副作用を実行したい場合はuseLayoutEffect()という異なるHooksを用いることができます。

アンマウント

クラスコンポーネントではコンポーネントがアンマウントされて破棄される直前に実行する処理をcomponentWillUnmount()に記述します。
関数コンポーネントではuseEffect()の返り値にアンマウント時の処理を記載します。加えて、コンポーネントのアンマウント時だけでなく、新しい副作用を実行する前に前回の副作用をクリーンアップするためにも使用します。
そのため初期化が更新のために走ってしまうので、useMemo()useCallback()を使用してメモ化して最適化を図るとよいでしょう。 メモ化については長くなるので、別の機会に書きたいと考えています。

import { Component } from 'react';
import {setupConnection, destroyConnection} from './SomethingConnection';

class MyComponent extends Component {
constructor(props) {
super(props);
this.state = { count: 0 };
}

componentDidMount() {
setupConnection();
document.title = `You clicked ${this.state.count} times`;
}

componentDidUpdate(prevProps, prevState) {
if(prevState.count !== this.state.count) {
document.title = `You clicked ${this.state.count} times`;
}
}

componentWillUnmount(){
destroyConnection();
}

render() {
return (
<div>
<p>Count: {this.state.count}</p>
<button onClick={() => this.setState({ count: this.state.count + 1 })}>
Click me
</button>
</div>
);
}
}

export default MyComponent;
import { useState } from 'react';
import {setupConnection, destroyConnection} from './SomethingConnection';

const MyComponent = (props) => {
const [count, setCount] = useState(0);
useEffect(() => {
document.title = `You clicked ${count} times`;
},[count]);
useEffect(() => {
setupConnection();
return () => {
destroyConnection();
}
},[])

return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
</div>
);
};

export default MyComponent;

エラーハンドリング

クラスコンポーネントでは、子コンポーネントツリーのどこかでエラーを捕捉し、クラッシュしたコンポーネントツリーの代わりにフォールバックUIを表示するための「エラーバウンダリー」が用意されています。
エラーに反応して更新されるstateを用意して、static getDerivedStateFromError()の返り値でstateを更新することでフォールバックUIを表示します。
ロギング時のエラーなど副作用を扱う場合はcomponentDidCatch()を使用します。

class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}

static getDerivedStateFromError(error) {
return { hasError: true };
}

componentDidCatch(error, info) {
logComponentStackToMyService(info.componentStack);
}

render() {
if (this.state.hasError) {
return this.props.fallback;
}

return this.props.children;
}
}

定義したエラーバウンダリーでコンポーネントをラップすることで、ラップされたコンポーネント以下のエラーハンドリングができるようになります。

<ErrorBoundary fallback={<p>Something went wrong</p>}>
<MyComponent />
</ErrorBoundary>

関数コンポーネントではエラーバウンダリーは削除されています。必要な場合はreact-error-boundaryなどを用いることで実装できます。

おわりに

クラスコンポーネントと関数コンポーネントの違いについて、ライフサイクルや状態管理の観点から比較してみました。
思想の違いから大きく書き方が変わっていますが、関数コンポーネントでの書き方の方が洗練されていてシンプルな記述ができるようになっています。
関数コンポーネントでは副作用を全般的にuseEffect()で扱うので、適切に扱わないとパフォーマンス低下の原因となります。
役割を意識して適切なHooksの使い分けができるよう、次はuseMemo()useCallback()などの各種Hooksについてもまとめたいと考えています。