🚧

ReactのClass Componentでthisを参照しつつhooksを使うワークアラウンド

2023/11/27に公開

はじめに

ReactのFunction Componentにhooksが導入されてから、既に5年ほど経ちました。Class Componentを使う機会はほぼなくなってしまった、という方も多いと思います。公式ドキュメントでもClass Componentは非推奨な扱いになっていて、Function Componentへの移行が推奨されています。Next.jsの実装するRSCではClass ComponentをServer Componentとして使うことが許可されていなかったりもします。

実際、componentDidCatchgetSnapshotBeforeUpdateなど一部Class Componentにしか用意されていないlifecycle methodはまだあるものの、普通に使っている分にはFunction Componentで事足りるため、昔作成したClass ComponentをFunction Componentへ書き直しを進めている/既に書き直しを完了した方も多いと思います。

移行作業について、近年ではChatGPTやGitHub Copilotにやらせることもできますし、react-declassifyのようなツールもあります。一昔前よりは移行もやりやすくなっていると思います。

https://github.com/wantedly/react-declassify

ただ、シンプルなケースであれば何の問題もないですが、Class Component特有の挙動に依存した実装がある場合、単純な変換が難しいケースがあります。

例えばcallback内からpropsにアクセスしたいケースを想定すると、Class Componentではthisを介して常に最新のpropsにアクセスできますが、Function Componentではクロージャ内にあるrender時点のpropsにしか通常はアクセスできません。

https://overreacted.io/how-are-function-components-different-from-classes/

勿論、Reactは後方互換性を十分担保しながらアップデートを行っているので、敢えてClass Componentのまま移行しないという判断もありうると思います。現に、かつてdeprecatedになったcreateReactClassで書かれたcomponentも、shimを使えば動きます。Class Componentも将来的にこのような扱いになっていく可能性もあると思います。

でも私はhookが使いたい…少しずつでも移行を進めたい…そんな時に使えるかもしれない、ですがあまりお勧めはできないワークアラウンドです。

実例

以下がよくあるClass Componentの例です。

before
interface HelloProps {
  foo: string;
}
interface HelloState {
  bar: boolean;
}

class Hello extends Component<HelloProps, HelloState> {
  constructor(props: HelloProps) {
    super(props);
    this.state = { bar: false };
  }

  onClick = () => {
    this.setState((prev) => ({ bar: !prev.bar }));
  };

  render() {
    return (
      <button onClick={this.onClick}>
        {this.state.bar ? this.props.foo : "hello"}
      </button>
    );
  }
}

以下が上記のComponentを出来る限りそのままに、useEffectを導入してみた例です。

after
interface HelloProps {
  foo: string;
}
interface HelloState {
  bar: boolean;
}

class Hello extends Component<HelloProps, HelloState> {
  constructor(props: HelloProps) {
    super(props);
    this.state = { bar: false };
  }

  onClick = () => {
    this.setState((prev) => ({ bar: !prev.bar }));
  };

  render() {
    return <this.Inner />;
  }

  Inner = () => {
    useEffect(() => {
      console.log('Effect!');
    }, [])
    return (
      <button onClick={this.onClick}>
        {this.state.bar ? this.props.foo : "hello"}
      </button>
    );
  };
}

ポイントは、propsやstateが更新された時に必ずrender()が実行される点、(memoなどを使っていない限り)render()配下のcomponentは全て再renderingされる点です。
なので、このようにClass Componentのinstance fieldとしてFunction Componentを持つことで、元の実装を出来る限り残したままhookが使えます。

デメリットとしては、eslintが正しく効かないケースがあります。例えばuseEffectの中でthis.props.fooを参照しているとして、depsにthis.props.fooを含め忘れても、eslint-plugin-react-hooksに怒られません。

あとはReactが想定していないだろう方法なので、何か実装を誤った場合、想定通りレンダリングされない可能性はあるかもしれません。Suspenseなどconcurrent featureと絡めた時に、何か不都合があるかもしれません。一応この状態のcomponentを数ヶ月間productionで動作させていますが、今の所はこれに起因する問題は確認されていません。

おわりに

こんな方法を使わないで済むならその方が良いです。さっさとFunction Componentに移行しましょう。

FRAIMテックブログ

Discussion