1. ホーム
  2. React

【React】クラスコンポーネントの書き方|state・ライフサイクルメソッドと関数コンポーネントとの違い

Share

React の新しいコードは関数コンポーネントとフックで書くのが標準になりましたが、クラスコンポーネントの知識が不要になったわけではありません。少し前に作られたプロジェクトを引き継げば class extends React.Component の書き方が当たり前に出てきますし、Error Boundary(描画中のエラーを捕まえる仕組み)は今もクラスでしか書けません。この記事では、クラスコンポーネントの基本形から state の更新、this の束縛、主要なライフサイクルメソッドまでを順に解説し、関数コンポーネントとフックのどれに対応するのかを整理します。

今クラスコンポーネントを知っておく理由

React 公式のドキュメントは、新しく書くコンポーネントには関数コンポーネントを使うことを推奨しています。フックのほうがロジックを再利用しやすく、コードも短くなるためです。それでもクラスコンポーネントを読める必要があるのは、主に2つの理由からです。

1つ目は、既存コードの読解です。フックが登場したのは React 16.8(2019年)で、それ以前に作られたコンポーネントはすべてクラスで書かれています。業務で保守や機能追加を任されたとき、componentDidUpdate の中で何が起きているのか分からなければ手が出せません。

2つ目は、クラスでしか書けない機能が残っていることです。具体的には static getDerivedStateFromError()componentDidCatch() の2つで、これらを使う Error Boundary には対応するフックが用意されていません。React 19 時点でもこの状況は変わっておらず、アプリ全体をエラーから守るコンポーネントを自作するならクラスを書くことになります。

クラスコンポーネントの基本形

クラスコンポーネントは React.Component を継承したクラスとして定義し、render() メソッドで JSX を返します。関数コンポーネントが「関数そのものが render の役割を持つ」のに対し、クラスでは render() だけがその役割を担い、残りのメソッドは別の目的で使われます。

Greeting.jsx
import React from 'react';

class Greeting extends React.Component {
  // render() は必須。JSX を return する
  render() {
    // props は引数ではなく this.props から読み取る
    return <h1>こんにちは、{this.props.name}さん</h1>;
  }
}

export default Greeting;

使う側の書き方は関数コンポーネントとまったく同じで、<Greeting name="太郎" /> と書けます。React はコンポーネントがクラスか関数かを内部で判別して呼び分けるため、利用者側が意識する必要はありません。

import { Component } from 'react'; と名前付きインポートしておけば、class Greeting extends Component と短く書けます。既存コードではどちらの書き方も見かけます。

constructor と super(props) の役割

state の初期化やメソッドの束縛が必要なときは、constructor(props) を定義します。このとき最初に super(props) を呼ぶのがルールです。JavaScript のクラス構文では、派生クラスのコンストラクタ内で super() を呼ぶまで this を参照できません。さらに props を渡さないと、コンストラクタ内で this.propsundefined になります。

Greeting.jsx
class Greeting extends React.Component {
  constructor(props) {
    // 最初に super(props) を呼ぶ
    super(props);

    // これ以降 this が使える
    this.state = { isOpen: false };

    // super() に props を渡さないと、ここが undefined になる
    console.log(this.props.name);
  }

  render() {
    return <h1>{this.props.name}</h1>;
  }
}

紛らわしいのは、super() と引数なしで書いても render() やイベントハンドラの中では this.props が正しく読める点です。これは React がインスタンス生成後に props を設定しているためで、影響を受けるのはコンストラクタの中だけです。とはいえ混乱の元になるので、常に super(props) と書く習慣にしておくのが安全です。

なお、クラスフィールド構文(ES2022 で標準化)が使える環境なら、コンストラクタを書かずに state を初期化できます。この書き方が使えるなら、こちらのほうが簡潔です。

Greeting.jsx
class Greeting extends React.Component {
  // constructor なしで state を初期化できる
  state = { isOpen: false };

  render() {
    return <h1>{this.props.name}</h1>;
  }
}

state の初期化と this.setState の使い方

クラスコンポーネントの state は、フックのように値ごとに分かれてはいません。1つのオブジェクトにすべての状態をまとめて持つのが特徴です。関数コンポーネントで useState を3回呼ぶような場面でも、クラスでは this.state の中に3つのキーを持たせます。

Counter.jsx
import React from 'react';

class Counter extends React.Component {
  constructor(props) {
    super(props);
    // 状態は1つのオブジェクトにまとめる
    this.state = {
      count: 0,
      message: '',
    };
  }

  render() {
    return (
      <div>
        <p>現在のカウント: {this.state.count}</p>
        <p>{this.state.message}</p>
      </div>
    );
  }
}

export default Counter;

state を更新するときは必ず this.setState() を呼びます。this.state.count = 1 のように直接書き換えても React は変更に気づかず、画面は更新されません。直接代入してよいのは、コンストラクタで初期値を入れるときだけです。

オブジェクトを渡す形式

もっとも基本的な使い方は、更新したいキーだけを持つオブジェクトを渡す方法です。ここで重要なのは、渡さなかったキーは元の値のまま残ることです。React が既存の state に対して浅いマージ(第一階層だけの結合)を行うため、message だけを更新しても count は消えません。

Counter.jsx
// message だけを更新する(count は 0 のまま保持される)
this.setState({ message: '保存しました' });

この挙動は useState と大きく違う点です。フックでオブジェクトを state に持つ場合、セッター関数は値をそのまま置き換えるので、自分でスプレッド構文を書いて既存の値を引き継ぐ必要があります。クラスから関数コンポーネントへ書き換えるときに漏れやすい差分です。

ただし、マージされるのは第一階層だけです。this.state.form.name のようなネストした値を更新したいときは、form オブジェクト全体を自分で作り直して渡します。

ProfileForm.jsx
// NG: form の他のキーが消えてしまう
this.setState({ form: { name: '太郎' } });

// OK: 内側は自分で展開する
this.setState((prevState) => ({
  form: { ...prevState.form, name: '太郎' },
}));

直前の state を使うときは関数形式にする

setState() は呼んだ瞬間に this.state を書き換えるわけではありません。React は同じイベントの中で発生した複数の更新をまとめて処理し(バッチング)、その後に一度だけ再レンダーします。そのため、直前の state を this.state から読んで計算すると、古い値を参照してしまいます。

Counter.jsx
// 期待は +3 だが、3回とも同じ this.state.count を読むので +1 にしかならない
this.setState({ count: this.state.count + 1 });
this.setState({ count: this.state.count + 1 });
this.setState({ count: this.state.count + 1 });

// 関数形式なら順番に適用されるので +3 になる
this.setState((prevState) => ({ count: prevState.count + 1 }));
this.setState((prevState) => ({ count: prevState.count + 1 }));
this.setState((prevState) => ({ count: prevState.count + 1 }));

関数形式では、第1引数に更新直前の state、第2引数にそのときの props が渡されます。返したオブジェクトが state にマージされる点はオブジェクト形式と同じです。アロー関数でオブジェクトを返すときは、{} がブロックと解釈されないように ({ ... }) と丸括弧で包むのを忘れないでください。

Counter.jsx
// 第2引数で props も受け取れる
this.setState((prevState, props) => ({
  count: Math.min(prevState.count + 1, props.max),
}));

更新後に処理したいときはコールバック引数を使う

setState() の第2引数には、state が反映され画面が再描画された後に実行される関数を渡せます。「更新後の値をサーバーに送りたい」「新しい高さを測りたい」といった処理はここに書きます。setState() の直後に this.state を読んでも古い値のままなので、この引数を使うのが正しい方法です。

SearchBox.jsx
handleChange(event) {
  this.setState({ keyword: event.target.value }, () => {
    // 再レンダー後に呼ばれる。ここでは新しい値が入っている
    this.search(this.state.keyword);
  });

  // ここで読むとまだ古い値
  console.log(this.state.keyword);
}

関数コンポーネントにはこのコールバックに相当する引数がありません。同じことをしたい場合は、対象の state を依存配列に入れた useEffect で処理します。

イベントハンドラで this が undefined になる理由

クラスコンポーネントで最初につまずくのが this の扱いです。onClick={this.handleClick} のようにメソッドを渡すと、呼び出されるときにはメソッドがクラスから切り離された「ただの関数」になっており、thisundefined になります。JSX を含むファイルは strict mode で実行されるため、this がグローバルオブジェクトにフォールバックすることもありません。

結果として Cannot read properties of undefined (reading 'setState') というエラーになります。これはクラス構文そのものの仕様で、React 固有の問題ではありません。対処法は2つあります。

constructor で bind する

古いコードでよく見かけるのが、コンストラクタで bind() を使ってメソッドを固定する方法です。bind()this を束縛した新しい関数を返すので、それを同名のプロパティに代入し直します。

Toggle.jsx
import React from 'react';

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

    // ここで束縛しないと handleClick 内の this が undefined になる
    this.handleClick = this.handleClick.bind(this);
  }

  handleClick() {
    this.setState((prevState) => ({ isOn: !prevState.isOn }));
  }

  render() {
    return (
      <button type="button" onClick={this.handleClick}>
        {this.state.isOn ? 'ON' : 'OFF'}
      </button>
    );
  }
}

export default Toggle;

クラスフィールドのアロー関数で書く

現在おすすめなのは、メソッドをクラスフィールドのアロー関数として定義する方法です。アロー関数は定義された場所の this をそのまま引き継ぐため、束縛の記述が不要になります。ハンドラを増やすたびに bind() を書き足す手間もありません。

Toggle.jsx
import React from 'react';

class Toggle extends React.Component {
  state = { isOn: false };

  // アロー関数なので this はインスタンスに固定される
  handleClick = () => {
    this.setState((prevState) => ({ isOn: !prevState.isOn }));
  };

  render() {
    return (
      <button type="button" onClick={this.handleClick}>
        {this.state.isOn ? 'ON' : 'OFF'}
      </button>
    );
  }
}

export default Toggle;

3つ目の選択肢として onClick={() => this.handleClick()} と JSX の中でアロー関数を書く方法もありますが、レンダーのたびに新しい関数が生成されます。子コンポーネントに props として渡す場合、後述する PureComponent の比較が毎回失敗して最適化が効かなくなるため、引数を渡したいとき以外は避けたほうがよいでしょう。

主なライフサイクルメソッド

ライフサイクルメソッドは、コンポーネントが画面に現れてから消えるまでの節目で React が自動的に呼び出すメソッドです。データ取得やタイマー登録、後片付けといった「レンダー以外の処理」をここに書きます。よく使うのは次の3つです。

メソッド呼ばれるタイミング主な用途
componentDidMount()初回レンダー後、DOM に追加された直後に1回だけデータ取得、購読の開始、タイマー設定
componentDidUpdate(prevProps, prevState, snapshot)props か state が変わって再レンダーされた後(初回は呼ばれない)変更に応じた再取得、DOM の同期
componentWillUnmount()コンポーネントが画面から取り除かれる直前タイマー解除、購読解除、通信のキャンセル

この3つを組み合わせた典型例が、props で受け取った ID をもとにデータを取得するコンポーネントです。

UserProfile.jsx
import React from 'react';

class UserProfile extends React.Component {
  state = { user: null, isLoading: true };
  controller = null;

  componentDidMount() {
    // マウント直後に1回だけ実行される
    this.fetchUser(this.props.userId);
  }

  componentDidUpdate(prevProps) {
    // 前回の props と比較してから実行する
    if (prevProps.userId !== this.props.userId) {
      this.fetchUser(this.props.userId);
    }
  }

  componentWillUnmount() {
    // 消えた後に setState しないよう通信を中断する
    if (this.controller) {
      this.controller.abort();
    }
  }

  fetchUser(userId) {
    if (this.controller) {
      this.controller.abort();
    }
    this.controller = new AbortController();
    this.setState({ isLoading: true });

    fetch(`/api/users/${userId}`, { signal: this.controller.signal })
      .then((response) => response.json())
      .then((user) => this.setState({ user, isLoading: false }))
      .catch((error) => {
        if (error.name !== 'AbortError') {
          this.setState({ user: null, isLoading: false });
        }
      });
  }

  render() {
    const { user, isLoading } = this.state;

    if (isLoading) {
      return <p>読み込み中...</p>;
    }
    if (!user) {
      return <p>ユーザーが見つかりませんでした。</p>;
    }
    return <h1>{user.name}</h1>;
  }
}

export default UserProfile;

componentDidUpdate で prevProps の比較が必須な理由

上のコードで if (prevProps.userId !== this.props.userId) を外すと、アプリは無限ループに陥ります。componentDidUpdate() は再レンダーのたびに呼ばれ、その中の setState() がまた再レンダーを起こし、再び componentDidUpdate() が呼ばれる、という循環が止まらなくなるからです。

関数コンポーネントの useEffect では依存配列がこの役割を果たし、値が変わったときだけ処理が走ります。クラスには依存配列がないため、「本当に変わったか」を自分で比較して条件分岐する必要があります。componentDidUpdate() の中で無条件に setState() を呼んでいるコードを見つけたら、まずここを疑ってください。

state を比較したい場合は第2引数の prevState を使います。第3引数の snapshot には、後述する getSnapshotBeforeUpdate() の戻り値が渡されます。

componentWillUnmount と useEffect のクリーンアップの違い

後片付けの処理という点では componentWillUnmount()useEffect のクリーンアップ関数は似ていますが、呼ばれる回数が違います。componentWillUnmount() はコンポーネントが消えるときに1回だけ呼ばれるのに対し、useEffect のクリーンアップは依存配列の値が変わって副作用を再実行する前にも毎回呼ばれます

この違いのおかげで、フックでは「購読を張り直す前に古い購読を解除する」処理が自然に書けます。クラスでは componentDidUpdate() の中で古い購読を解除し、新しい購読を張る、という記述を自分で書くことになります。上のコード例で fetchUser() の冒頭に abort() を入れているのも同じ理由です。

その他のライフサイクルメソッド

頻度は下がりますが、次の2つも仕様として現役です。static getDerivedStateFromProps() は props から state を導出するためのもので、静的メソッドなので this が使えません。使いどころが限られ、多くの場合は他の方法で書けるため、React 公式も「めったに使わない」としています。

ScrollList.jsx
class ScrollList extends React.Component {
  listRef = React.createRef();

  // DOM が更新される直前に呼ばれ、戻り値が componentDidUpdate に渡る
  getSnapshotBeforeUpdate(prevProps, prevState) {
    if (prevProps.items.length < this.props.items.length) {
      const list = this.listRef.current;
      return list.scrollHeight - list.scrollTop;
    }
    return null;
  }

  componentDidUpdate(prevProps, prevState, snapshot) {
    // 第3引数で受け取り、スクロール位置を維持する
    if (snapshot !== null) {
      const list = this.listRef.current;
      list.scrollTop = list.scrollHeight - snapshot;
    }
  }

  render() {
    return <ul ref={this.listRef}>{/* 省略 */}</ul>;
  }
}

getSnapshotBeforeUpdate() は、DOM が実際に書き換わる直前の情報(スクロール位置やサイズ)を記録しておき、更新後にそれを使いたいときに役立ちます。このメソッドにも対応するフックはありません。

Error Boundary はクラスでしか書けない

クラスコンポーネントが今も必要とされる唯一の理由が、static getDerivedStateFromError()componentDidCatch() です。この2つを持つコンポーネントは Error Boundary として扱われ、配下のコンポーネントのレンダー中に投げられたエラーを捕まえて、代わりの画面を表示できます。

ErrorBoundary.jsx
import React from 'react';

class ErrorBoundary extends React.Component {
  state = { hasError: false };

  // レンダー中に呼ばれる。返した値が state にマージされる
  static getDerivedStateFromError(error) {
    return { hasError: true };
  }

  // 画面が更新された後に呼ばれる。ログ送信などの副作用はこちら
  componentDidCatch(error, info) {
    console.error(error, info.componentStack);
  }

  render() {
    if (this.state.hasError) {
      return <p>表示中に問題が発生しました。</p>;
    }
    return this.props.children;
  }
}

export default ErrorBoundary;

2つのメソッドが分かれているのは、React のレンダーフェーズでは副作用を起こしてはいけないためです。画面の切り替えは getDerivedStateFromError()、外部へのログ送信は componentDidCatch()、と役割が分けられています。

捕捉できるのはレンダー中・ライフサイクルメソッド内・コンストラクタ内のエラーだけで、イベントハンドラや非同期処理の中で投げられたエラーは対象外です。それらは通常どおり try...catch で処理します。

再レンダーを抑える shouldComponentUpdate と PureComponent

shouldComponentUpdate(nextProps, nextState) は、再レンダーするかどうかを自分で決められるメソッドです。false を返すと render() が呼ばれず、DOM の更新もスキップされます。

Row.jsx
class Row extends React.Component {
  shouldComponentUpdate(nextProps) {
    // name が変わったときだけ再レンダーする
    return nextProps.name !== this.props.name;
  }

  render() {
    return <li>{this.props.name}</li>;
  }
}

比較の内容が「すべての props と state を浅く比べるだけ」でよいなら、React.PureComponent を継承するほうが簡単です。PureComponentshouldComponentUpdate() に浅い比較をあらかじめ実装したクラスで、自分で比較コードを書く必要がありません。

Row.jsx
// props と state を浅く比較し、変化がなければ再レンダーしない
class Row extends React.PureComponent {
  render() {
    return <li>{this.props.name}</li>;
  }
}

これは関数コンポーネントの React.memo() に対応します。どちらも比較は浅い比較なので、親が毎回新しいオブジェクトや関数を props に渡していると、中身が同じでも「変わった」と判定されて効果がなくなる点も共通です。JSX 内でアロー関数を書くのを避けたほうがよいのは、この比較を壊さないためです。

なお、PureComponent を継承したうえで shouldComponentUpdate() を自分で定義すると、開発時に警告が出ます。どちらか一方にしてください。

UNSAFE_ が付いた古いライフサイクルメソッド

数年前のコードを読むと、componentWillMount()componentWillReceiveProps()componentWillUpdate() という「Will」系のメソッドが出てくることがあります。これらは React 16.3 で非推奨となり、UNSAFE_componentWillMount() のように接頭辞の付いた名前に改められました。

安全でないとされたのは、これらがレンダーフェーズ(画面への反映前)に呼ばれるためです。React 18 以降の並行レンダリングでは、レンダーが途中で中断されたり、同じレンダーが複数回試行されたりします。そのタイミングで通信を開始したり購読を登録したりすると、処理が二重に走る、あるいは解除されないまま残るといった不具合につながります。

置き換え先ははっきりしています。componentWillMount() の中の初期化処理はコンストラクタか componentDidMount() へ、componentWillReceiveProps()componentDidUpdate() での比較か static getDerivedStateFromProps() へ移します。新しく書くコードで UNSAFE_ 付きのメソッドを選ぶ理由はありません。

関数コンポーネントとの対応関係

クラスの各機能が関数コンポーネントのどれに対応するかを一覧にすると、書き換えの見通しが立ちます。

クラスコンポーネント関数コンポーネントでの書き方
this.props関数の引数(function C(props)
this.statethis.setState()useState(値ごとに分ける)または useReducer
render()関数本体の return
componentDidMount()useEffect(() => { ... }, [])
componentDidUpdate()useEffect(() => { ... }, [deps])(依存配列が比較の代わり)
componentWillUnmount()useEffect が返すクリーンアップ関数
shouldComponentUpdate()React.memo()(props の比較のみ)
React.PureComponentReact.memo()
static contextType / Context.ConsumeruseContext
インスタンス変数(this.timerId など)useRef
setState() の第2引数コールバック対象の state を依存配列に入れた useEffect
getSnapshotBeforeUpdate()対応するフックなし
getDerivedStateFromError() / componentDidCatch()対応するフックなし(クラスが必要)

実際に書き換えると、次のような対応になります。まずクラス版です。

Clock.jsx(クラス版)
import React from 'react';

class Clock extends React.Component {
  state = { now: new Date() };
  timerId = null;

  componentDidMount() {
    this.timerId = setInterval(() => {
      this.setState({ now: new Date() });
    }, 1000);
  }

  componentWillUnmount() {
    clearInterval(this.timerId);
  }

  render() {
    return <p>{this.state.now.toLocaleTimeString()}</p>;
  }
}

export default Clock;
Clock.jsx(関数版)
import { useEffect, useState } from 'react';

function Clock() {
  const [now, setNow] = useState(() => new Date());

  useEffect(() => {
    const timerId = setInterval(() => {
      setNow(new Date());
    }, 1000);

    // componentWillUnmount に相当する後片付け
    return () => clearInterval(timerId);
  }, []);

  return <p>{now.toLocaleTimeString()}</p>;
}

export default Clock;

書き換えのコツは、ライフサイクルメソッド単位ではなく「目的」単位で移すことです。クラスでは「タイマーを開始する処理」と「解除する処理」が別々のメソッドに離れていますが、useEffect では1か所にまとまります。逆に、componentDidMount() の中で複数の無関係な処理をしている場合は、目的ごとに useEffect を分けたほうが読みやすくなります。

また、クラスの state は1つのオブジェクトでしたが、関数コンポーネントでは関連しない値ごとに useState を分けるのが基本です。まとめて持ち続けたい場合は、更新のたびにスプレッド構文で既存の値を引き継ぐ必要がある点に注意してください。

TypeScript で型を付ける場合

TypeScript のプロジェクトでクラスコンポーネントを扱うときは、Component の型引数に props と state の型を順に渡します。

Counter.tsx
import { Component } from 'react';

type CounterProps = { label: string; step?: number };
type CounterState = { count: number };

class Counter extends Component<CounterProps, CounterState> {
  state: CounterState = { count: 0 };

  handleClick = () => {
    this.setState((prevState) => ({
      count: prevState.count + (this.props.step ?? 1),
    }));
  };

  render() {
    return (
      <button type="button" onClick={this.handleClick}>
        {this.props.label}: {this.state.count}
      </button>
    );
  }
}

export default Counter;

型引数を書いておくと、this.propsthis.state に補完が効き、setState() に存在しないキーを渡したときもエラーになります。

まとめ

クラスコンポーネントは React.Component を継承し、render() で JSX を返すコンポーネントです。props は this.props から読み、state は constructor で1つのオブジェクトとして初期化して this.setState() で更新します。setState() は浅いマージを行うため渡さなかったキーは保持され、直前の state を使う更新では this.setState((prevState) => ({ ... })) の関数形式が必要です。更新後に処理したい場合は第2引数のコールバックを使います。イベントハンドラでは this が失われるため、constructor での bind() か、クラスフィールドのアロー関数で束縛します。ライフサイクルは componentDidMount()componentDidUpdate()componentWillUnmount() が中心で、componentDidUpdate() には依存配列がないため prevProps との比較を自分で書かないと無限ループになります。再レンダーの抑制は shouldComponentUpdate()React.PureComponent、関数コンポーネントでは React.memo() が対応します。UNSAFE_ 付きのメソッドは並行レンダリングと相性が悪いので使いません。ほとんどの機能はフックに置き換えられますが、Error Boundary に必要な static getDerivedStateFromError()componentDidCatch() だけは今もクラスでしか書けません。

参考ページ