ReactのコードをHooksにリファクタリングしていく話

フロントエンジニアの渡辺(@pentla)です。

AI 緊急情報サービスの「FASTALERT」は、Reactをフロントエンドのスタックとして採用しており、3年ほどコードベースは大きく変えずに運用しています。その過程で、Flow → TypeScriptへのスタックの変更など、継続的にリファクタリングを進めています。

tech.jxpress.net

今回は、Classベースのコンポーネントを、Hooksを使って関数ベースのコンポーネントに書き直す話です。

今回の対象読者は、

  • Reactを書いたことがある方
  • Reactのパフォーマンスについて気になっている方
  • Hooksは知っているけれど、プロダクション環境に入れるか迷っている方

を対象にしています。

Hooksとは

v16.8から追加された機能です。今までは、Stateやライフサイクルメソッド(表示時に一度だけ呼び出したいときなど)の機能はClassを使って書く必要があったのですが、このHooksを使えば関数ベースのコンポーネントでもStateを扱えたり、「表示後に一度だけ呼び出したい」などの要件を満たすことができるようになりました。他にもHooksを使うことで利点があり、このあたりに書かれているので、実際に使おうと思っている方は一読の価値ありです。

ja.reactjs.org

それぞれのHooksの特徴

hooksはライフサイクルの考え方が少し特殊で、最初はやや違和感があるかもしれませんが、少しずつ慣れていくと、思い通りにコンポーネントの状態を管理することができます。 簡単なhooksの紹介をします。

useState

stateの書き方を変えるだけなので、最もわかりやすいです。

// before
constructor() {
  this.state = {
      isSample = true
  }
} 

// after
[isSample, setSample] = useState(false)

公式ドキュメント「useState」

useEffect

componentDidUpdateなど、特定のタイミングでのみ起動させたいときは、useEffectを使います。 気を付けたいのは、第二引数です。useEffectの中で使用している変数、関数についてはすべてuseEffectの第二引数の配列に入れます。この引数に値を入れることで、「この中の値に変化があった場合のみ、useEffectの中の処理を走らせる」ことができます。

// before
componentDidMount() {
  this.callAPI(this.props.id);
}

// after
useEffect(() => {
  callAPI(props.id)
}, [props.id, callAPI])

公式ドキュメント「ヒント: 副作用のスキップによるパフォーマンス改善」に詳しい処理の内容があります。

リファクタリングのモチベーション

Reactのライフサイクルメソッドに、今後いくつかの修正が入ります。具体的には、ComponentWillReceiveProps、ComponentWillMount、ComponentWillUpdateの3つの関数がdeprecatedになります。

この機能は、Reactのv17.0以降削除されることが決まっていて、その際はUNSAFE_というプレフィックスをつけることが求められます。 新しいライフサイクルメソッドを使って書き直すことも可能ですが、せっかくの機会なので、少しずつ関数ベースのコンポーネントに書き直しています。

Hooksに書き直したときの利点

コード量が減った

明らかに減ります。このようにstateを少し使った簡単なコンポーネントであれば、行数もかなりの数減らせることができます。21行から9行と、半分程度まで減っていますね!

Classベースのコンポーネント: 21行

export default class MemberList extends PureComponent<Props, State> {
  constructor(props: Props) {
    super(props)
    this.state = { filter: { name: '' } }
    this.handleClickRow = this.handleClickRow.bind(this)
  }

 handleClickRow(rawId: number) {
    history.push(`/xxxx/${rawId}`)
  }

  render() {
    const { members } = this.props
    const {
      filter: { name }
    } = this.state
    // フィルタ機能
    const filteredMember = (members && members.filter(m => m.name.includes(name))) || []
    return <div>{...レイアウト}</div>
  }
}

関数ベースのコンポーネント: 10行

const OrganizationList: React.FC<Props> = ({ organizations }) => {
  const [filter, setFilter] = useState('')
  const handleClickRow = (rawId: number) => history.push(`/xxxx/${rawId}`)
  // フィルタ機能
  const filteredMember = useMemo(() => members && members.filter(o => o.name.includes(filter))) || [], [members, filter])
  return <div>{...レイアウト}</div>
}

export default MemberList

パフォーマンスの考え方がわかりやすくなった

Memo化を使うと、描画の際のパフォーマンスの調整が簡単になります。今までは、PureComponentsなどで描画回数を制限するなどしていましたが、useCallback, useMemoなどメモ化を使用することで変数ごとに計算回数の調整がしやすいです。

今までは、shouldComponentUpdate関数を使って、「このコンポーネントをいつ描画するか」を管理する必要がありました。例えば、下は、webページにタイトルを表示するReact.Compoentの例です。

class Title extends React.Component {
  render() {
   // すっごく重い処理
   const title = heavyFunction(props.title)
   return <div>{props.title}</div>
  }
}

class Parent extends React.Component {
  render() {
    return (
      <div>
        <Title title={this.props.title}/>
        {...その他いろいろなコンポーネント}
      </div>
    )
  }

Titleコンポーネントには、すっごく重い処理が書かれています。Titleコンポーネントは、Parentコンポーネントの内部にあります。 仮に、Parentコンポーネントが再度描画された場合、このTitleコンポーネントは変更がないにもかかわらず、再度描画処理が走ってしまいます。この場合、titleは変更がないにもかかわらず大きい処理が走ってしまい、パフォーマンスに影響が出るケースがあります。

そのようなケースに対応するために、

  • shouldComponentUpdate関数で、props.titleに変更がなければ描画しないようにする
  • pureComponentを使う

などの対策を取る必要がありました。

Memo化を使うと、このようになります。

const Title = props => {
  // すっごく重い処理は、props.titleが変更されない限り走らない
  const title = useMemo(() => heavyFunction(props.title), [props.title])
  return <div>{props.title}</div>
}

useMemoを使うことで、props.titleに変更がない限り、再度重い処理が走ることはありません。useMemoの使い方については、下にも説明を記載しています。

書き直していく上でのtips

ここからは、FASTALERTを実際にリファクタリングした時の話です。

小さめのコンポーネントから取り組む

特に最初はuseEffect、useCallbackなどの特徴を掴んでいくために、小さめのコードをFunctional Componentに書き直していくことをお勧めします。小さいコンポーネントであればその分修正も少なく、挙動の変更も確認しやすいです。

無理に書き直さない

特にテストがないコンポーネントの場合や、ライフサイクルメソッドに複雑な処理を書いている場合、大きな変更を入れるとどうしても変更が読めない箇所というのも出てしまいます。目的は将来のアップデートに追従するためで、必ず全てのコンポーネントをhooksを使って書き直す必要はないので、代わりのメソッドで置き換えるか、一旦後回しにする選択肢をとっています。

まとめ

今回のリファクタリングの途中で、

  • 書き直している途中に明らかにパフォーマンスが悪い箇所がある
  • APIの呼び出し箇所のコードがわかりづらく、簡潔でない

箇所に気づけたりして、問題になる前にコードの見直しができたのも良かったです。コードの行数も目に見えて減るので、効果がわかりやすいです。

さいごに - フロントエンドをいい感じにするフレンズまってます!

JX通信社では、FASTALERTやNewsDigestといったプロダクトの改善, 特にフロントエンドまわりを頑張っています。

上記以外にも現在進行系で行っている取り組みがあります、仲間を募集しているので興味があるかた気軽にお話に来てください!

www.wantedly.com