Sansan Tech Blog

Sansanのものづくりを支えるメンバーの技術やデザイン、プロダクトマネジメントの情報を発信

レガシーに向き合う - Reactのクラスコンポーネントを置き換える前にやるべきこと

こんにちは。Eightでエンジニアをしている藤野です。 Sansan Tech Blogに最後に記事を書いたのが2020年12月なので、約3年ぶりの投稿になります。時の流れって恐ろしい。

今回は、Reactのクラスコンポーネント(Class Component)を関数コンポーネント(Function Component)へ置き換える前にやるべきことについて話していこうと思います。

背景

EightのWeb版はReact + TypeScriptで書かれており、2024年3月で12年目となるプロジェクトです。日々改善はしていますが、長く運用してきただけあってフロントエンドのコードでも多少レガシーな部分が残っています。その中で挙げられる課題の一つとして、クラスコンポーネントがあります。

Reactは、初期段階ではコンポーネントは主にJavaScriptのクラスを利用したコンポーネントで記述されていました。Reactから提供されるComponentクラスを継承し、stateの管理やライフサイクルメソッドの使用ができるようになる、Reactの主要な機能を利用するための唯一の方法でした。しかし、クラスコンポーネントは、ライフサイクルメソッドやthisのバインディングなど、コードを複雑にする要素も含んでいました。

そして、その後登場したのが関数コンポーネントとhooksでした。関数コンポーネントはその名の通り、コンポーネントを関数で記述することができ、今までクラスコンポーネントでしか記述できなかったStateの管理やライフサイクルのメソッドはhooksという形で埋め込めるようになりました。また、関数コンポーネントはクラスコンポーネントと違い、コードがシンプルになるという利点があり、Eightとしても関数コンポーネント化(= FC化)を進めていくことになりました。

FC化置き換えのサンプル

実際にFC化を行う例を見てみましょう。以下に、stateを持つクラスコンポーネントがあります。

class SampleComponent extends Component<Props, { value: string }> {
  constructor(props: Props) {
    super(props);
    this.state = { value: "" };
  }

  get inputText() {
    return `input value: ${this.state.value}`
  }

  handleChange(event: ChangeEvent<HTMLInputElement>) {
    this.setState({ value: event.target.value });
  }

  render() {
    return (
      <div>
        <span>{this.inputText}</span>
        <input
          type="text"
          value={this.state.value}
          onChange={this.handleChange.bind(this)}
        />
      </div>
    );
  }
}

上記は、テキストの入力を行うInputFieldと、入力された値をspanで表示するコンポーネントです。

このクラスコンポーネントは、大きく以下の3つの要素に分解できます。

  1. constructorでの初期化
  2. クラスメソッド、getter、プロパティ
  3. render関数でのコンポーネントのレンダリング

これらの要素を関数コンポーネントとして整理すると以下のように置き換えられます。

constructorでの初期化

関数コンポーネントでは、基本的に関数同様に変数・関数を定義していくため、constructorではなく、関数上部に初期化の処理を記述します。stateやrefはそれぞれ用意されているhooksを用いることで表現することができます。

const [value, setValue] = useState(""); // value(string)のstate
const textInput = useRef<HTMLInputElement>(null); // <input />のref

クラスメソッド、getter、プロパティ

関数コンポーネントでは、クラスメソッドやゲッター・セッターなどは、通常通り関数を定義するか、useCallbackやuseMemoといったhooksを使うことで表現できます。

クラスコンポーネント 関数コンポーネント
getter useMemo
クラスメソッド useCallback
state useState
ライフサイクルメソッド useEffect
refやその他プロパティ useRef

ここで注意してほしい点として、this.xxx = yyy といったインスタンスに代入しているタイプのプロパティを置き換える際には、Refを使わなければならないということです。React.Componentを継承していますが、プロパティに関してはReactのライフサイクルには乗らない為、リアクティブではありません。もちろん、Refである必要のない場合もありますが、置き換えの際には極力実装を合わせる方が得策なため、基本的にはRefを用います。

基本的にはライフサイクルメソッドはコンポーネントの値が変化した時にリアクティブに発火するだけなので、useEffectとその依存配列を組み合わせることで実現することができます。

render関数でのコンポーネントのレンダリング

render関数は関数コンポーネントでは廃止され、代わりに関数コンポーネントでreturnされる値が、そのままレンダリングされる内容として表示されます。

FC化の結果

上記を踏まえ、先ほどのクラスコンポーネントを置き換えたのがこちらです。

const SampleComponent = () => {
  const [value, setValue] = useState("");

  const inputText = useMemo(() =>`input value: ${value}`, [value]);

  const handleChange = useCallback((event) => setValue(event.target.value), []);

  return (
    <div>
      <span>{inputText}</span>
      <input
        type="text"
        value={value}
        onChange={handleChange}
      />
    </div>
  );
}

置き換える前にやったこと

慣れてしまえば、先ほどのサンプルのような簡単なコンポーネントはすぐにFC化することができます。ただ、そんな簡単に行くわけもなく、簡単には置き換えられないケースやレビューが困難であるケースが多々出てきます。

それらのケースに対応するべく、我々が行った前準備と取り決めについて紹介していきます。

使われていないPropsやState、変数の削除

クラスコンポーネントはレガシーが故に、過去には使われていたが現在では使われていないpropsやstate(消し残し)が存在するケースがあります。基本的にFC化を行う上ではコード量が少ない方が置き換えやすいことは自明であるため、これらを見つけた場合には事前に削除しておくことが望ましいです。

type LegacyComponentProps = {
    autoFocus?: boolean; // 定義されているが実際には使われていない
}

class LegacyComponent extends Component<LegacyComponentProps> {
  // ...

  // このメソッドも不要
  focus() {
    if(!autoFocus) return;
    // その他処理
  }
}

⚠️ここで注意しなければいけない点として、実際にその関数や変数は本当にどこからも呼ばれていないのかという点を確認するということです。 例えば、ts-expect-error等で型エラーを潰していたケースです。以下のコードを見てみましょう。

class Component extends Component<{ selectedDialog: "A" | "B" }> {
  // ...

  handleClick() {
    // @ts-expect-error this.A, this.BはComponentで初期化されてない
    this[this.props.selectedDialog].open();
  }

  render() {
    return (
      <>
        <button onClick={this.handleClick.bind(this)}>Open Dialog</button>
        {/* @ts-expect-error this.A, this.BはComponentで初期化されてない */}
        <Dialog ref={(ref) => this["A"] = ref} />
        {/* @ts-expect-error this.A, this.BはComponentで初期化されてない */}
        <Dialog ref={(ref) => this["B"] = ref} />
      </>
    );
  }
}

class Dialog extends Component<Props, { isOpen: boolean }> {
  open() {
    this.setState({ isOpen: true });
  }
  // ...
}

このように、JavaScriptからTypeScriptに乗り換えたプロジェクトなどでは、クラスコンポーネントのメンバ変数の初期化などは行っておらず型エラーが出ているが、実装を変更するまでの工数が取れていないという場合においてts-expect-errorで型エラーをもみ消している場合があります。実際にこのようなコードがEight WebのTypeScript化黎明期には存在していました。

このケースにおいてDialogコンポーネントのopenからは参照が途切れており、エディタ上では使われていないように見えてしまいます。Dialogコンポーネント内のみを見てopenメソッドを消してしまった場合、Component側のhandleClickが押下された瞬間画面落ちが発生するというわけです。そのため、コードを消す際には本当にどこからも呼ばれていないのかを入念に調べる必要があります(大事なことなので2回言いました)。

変数や関数の命名の修正

クラスコンポーネントでは、stateやprops、メンバ変数・関数の値が同名になってしまっているケースがあります。以下の例を見てください。

class Component extends Component<{ onClick: VoidFunction }>{
  onClick(e: MouseEvent<HTMLButtonElement>) {
    e.preventDefault();
    this.props.onClick();
  }
  // ...
}

このように、Componentのメンバ関数であるonClickと、propsで渡されるonClickは同名です。これをFC化すると以下のようになります。

const Component: FC<{ onClick: VoidFunction }> = ({ onClick }) => {
  // 🚨 onClickはすでにpropsで渡されているため関数内で同名のものを定義できない。
  const onClick = useCallback((e: MouseEvent<HTMLButtonElement>) => {
    e.preventDefault();
    onClick();
  }, [onClick]);
}

このように、単純に置き換えることができません。また、this.onClickなのかthis.props.onClickなのかどうかも置き換える際に混乱する可能性があるため、事前に命名を修正しておくのが好ましいです。

// ⭕️ OK
handleOnClick(e: MouseEvent<HTMLButtonElement>) {
  e.preventDefault();
  this.props.onClick()
}
const handleOnClick = useCallback((e: MouseEvent<HTMLButtonElement>) => {
  e.preventDefault();
  onClick()
}, [onClick]);

オブジェクトのプロパティの動的な参照を減らす

クラスコンポーネントの場合、メンバ変数やstateに置いて、以下のような記法で処理を書くことができます。

type FilterState = { price: string; age: number };
class FilterComponent extends Component<Props, FilterState> {
  constructor(props) {
    // filterの初期値をstateに代入する
    this.state = getFilterValue(props);
  }

  onChange<K extends keyof FilterState>(filterKey: K, value: FilterState[K]) {
    this.setState({ [filterKey]: value });
  }
  // ...
}

この場合、handleOnFilterChangeに渡されたキーによって動的にメンバ変数とstateにセットされる値が決定します。

関数コンポーネントの場合、stateはhooksを介して作成されるため現在のconstructorのような書き方はできません。素直に書こうとするとfilterの数だけhooksを呼び出し、stateを作成する必要があります。また、値をsetする際にはkeyの数だけ場合わけを増やしていく必要があります。

解決策として、stateの構造を修正し、 関数コンポーネントでも扱いやすいような形式に事前に修正しておくアプローチが考えられます。

type FilterBase = {
  price: string;
  age: number;
}
type FilterState = {
  filter: FilterBase;
}
class FilterComponent extends Component<Props, FilterState> {
  onChange<K extends keyof FilterState>(filterKey: K, value: FilterState[K]) {
    this.setState((prev) => ({
      filter: {
        // 更新されるkey以外はprevのstateを使う
        ...prev.filter,
        [filterKey]: value,
      }
    }));
  }
  // ...
}

Filterのような、今後もstateが増え続けると予想されるケースにおいてこれらは有効な手段です。

不要なライフサイクルメソッドの削除

FC化の難点の一つに、ライフサイクルメソッドの置き換えの難易度があります。先述した通り、関数コンポーネントでライフサイクルメソッドを再現するにはuseEffectを使う必要がありますが、完全に同じ挙動になっているかどうかのチェックは難易度が高いです。また、useEffectは依存配列の中身を吟味しないと、容易に無限ループが発生してしまうので障害の原因にもなります。

そのため、そもそもライフサイクルメソッドが本当に必要なのかどうかを事前に判断し、必要でない場合にはライフサイクルメソッドそのものを削除する(あるいは簡単にする)ことで、FC化を簡単にすることができます。以下は不要なライフサイクルを持ったダイアログの例です。

type Props = { isShow?: boolean };
class SuggestDialog extends Component<Props, { isOpen: boolean }> {
  constructor(props: Props) {
    this.state = { isOpen: props.isShow || false };
  }
  componentDidUpdate(prevProps) {
    if(this.props.isShow !== prevProps.isOpen) {
      this.setState({ isOpen: this.props.isShow || false });
    }
  }
  render() {
    if(!this.state.isOpen) return null;
    return <div>{/* content */}</div>;
  }
}

SuggestDialogにはisShowというpropsが渡され、その値をisOpenの初期値として代入しています。isOpenがfalseの場合にコンポーネントをレンダリングしません。propsのisShowの値の変更が検知されると、componentDidUpdateでそれをキャッチし、isShowの値をstateにセットします。

このコンポーネントをよくみると、propsのisShowとstateのisOpenは同じ役割をしていることがわかります。つまり、stateでisOpenを保持する必要はなく、propsのisShowだけで要件を満たせることがわかります。よって以下のように書き換えることができます。

class SuggestDialog extends Component<{ isShow?: boolean }> {
  render() {
    if(!this.props.isShow) return null;
    return <div>{/* content */}</div>;
  }
}

結果stateもcomponentDidUpdateも削除することができ、ただのプレゼンテーショナルなコンポーネントにすることができました。こうなればFC化も容易です。

継承の削除

Reactのクラスコンポーネントは、通常のJavaScriptのクラスと同様に継承を利用することができます。しかし、関数コンポーネントでは継承は存在しないため、継承を利用しているクラスコンポーネントを関数コンポーネントに置き換える際には、継承の機能を別の形で実現する必要があります。

継承を消していく方法として、二種類の方法が考えられます。継承されるコンポーネントを親、親コンポーネントを継承するコンポーネントを子と呼ぶと

  • 親に子の実装を押し込むパターン
  • 子に親の実装を押し込むパターン

があります。

それぞれの例を以下に示します。

親に子の実装を押し込むパターン

BaseComponentには、typeを渡すことで場合分けを行います。

type BaseProps = { type: 'Child1' | 'Child2'; }
class BaseComponent extends Component<BaseProps> {
  render() {
    if (this.props.type === 'Child1') {
      // Child1の実装
    } else if (this.props.type === 'Child2') {
      // Child2の実装
    }
  }
}

注意点として、typeが多い場合に親コンポーネントが肥大化する恐れがあります。

子に親の実装を押し込むパターン

一方このパターンは、親コンポーネントの共通の実装をそのまま子コンポーネントに適用します。

class Child1 extends Component {
  render() {
    // Baseの実装
    // Child1独自の実装
  }
}

class Child2 extends Component {
  render() {
    // Baseの実装
    // Child2独自の実装
  }
}

こちらも注意点として、BaseComponentが多数のコンポーネントで継承されていた場合、Childコンポーネントに対してコピーする数も増えるため、コード量や作業量が多くなる恐れがあります。

このように、いずれの方法も一長一短があります。したがって、どの方法を選ぶべきかは、プロジェクトの設計や要件によります。

まとめ

Reactのクラスコンポーネントから関数コンポーネントへの移行は、簡単なことではありません。命名の変更、ライフサイクルの見直し、コンポーネント設計の修正など、数多くのやるべきことがあります。

しかし十分に準備をし、負債を返済することで、プロダクトのコードのシンプルさ・可読性・保守性が向上し、コード品質や開発者体験の向上に繋がります。この記事で紹介した方法が、皆さんのFC化作業をスムーズに進めるための一歩目になれば幸いです。

また、Eight ではエンジニアを募集中です。 ご興味があれば、エントリーお待ちしております! open.talentio.com

© Sansan, Inc.