📌

Reactで再描画を抑える方法まとめ

2022/10/25に公開

この記事について

以下でReactの再描画の仕組みと抑制方法をスクラップしました。
折角なので、記事にしてまとめておきます。
https://zenn.dev/ishiyama/scraps/f5b25b3a608bdc

再描画の確認方法

Chromeデベロッパーツールを使用すると、いつ再描画が行われているか確認することができます。
該当箇所の外枠が発光し、再描画されていることを教えてくれます。また、負荷がかかるほど外枠が黄色に近い色に変わっていきます。

再描画の基本

React Hooksではコンポーネント内で定義されているStateが更新されたとき、そのコンポーネントの再描画が行われます。
そのコンポーネントがいくつかの子コンポーネントを持つとき、それら全てが再描画の対象となります。

Inputコンポーネント

以降で使用しているInputコンポーネントです。
基本的にはinputタグをラップしているだけなので、読み進める分には飛ばしてOKです。

Input.tsx
export type InputProps = Omit<JSX.IntrinsicElements["input"], "ref"> & {
  inputRef?: React.Ref<HTMLInputElement>
}

export const Input = ({ inputRef, ...props }: InputProps) => {
  return (
    <div style={{ margin: 10 }}>
      <input {...props} ref={inputRef} />
    </div>
  )
}

useState

素直にuseStatを使うとこう。

import { useState } from "react"
import { HeavyComponent } from "../../../components/HeavyComponent"
import { Input } from "../../../components/Input"

const Page = () => {
  const [input1, setInput1] = useState("")
  const [input2, setInput2] = useState("")

  const handleChange1 = (e: React.ChangeEvent<HTMLInputElement>) =>
    setInput1(e.target.value)

  const handleChange2 = (e: React.ChangeEvent<HTMLInputElement>) =>
    setInput2(e.target.value)

  return (
    <div>
      <Input value={input1} onChange={handleChange1} />
      <Input value={input2} onChange={handleChange2} />
      <small>{JSON.stringify({ input1, input2 })}</small>
      <HeavyComponent />
    </div>
  )
}

export default Page

入力のたびに全てのコンポーネントが再描画され、負荷が高くなっていくのがわかります。
この状態で作り込んでいくと、画面がカクつく原因となります。

React.memo

コンポーネントをメモ化します。
メモ化されたコンポーネントは、依存しているpropの値が変更されない限り、再描画されることはありません。

import { memo, useState } from "react"
import { HeavyComponent } from "../../../components/HeavyComponent"
import { Input, InputProps } from "../../../components/Input"

const InputMemo = memo((props: InputProps) => <Input {...props} />)
const HeavyComponentMemo = memo(() => <HeavyComponent />)

const Page = () => {
  const [input1, setInput1] = useState("")
  const [input2, setInput2] = useState("")

  const handleChange1 = (e: React.ChangeEvent<HTMLInputElement>) =>
    setInput1(e.target.value)

  const handleChange2 = (e: React.ChangeEvent<HTMLInputElement>) =>
    setInput2(e.target.value)

  return (
    <div>
      <InputMemo value={input1} onChange={handleChange1} />
      <InputMemo value={input2} onChange={handleChange2} />
      <small>{JSON.stringify({ input1, input2 })}</small>
      <HeavyComponentMemo />
    </div>
  )
}

export default Page

(分かりづらいですが、HeavyComponentのみ再描画が抑制されています。)

useCallback

HevayComponemtの再描画は抑制されていますが、テキストボックスは毎回再描画されています。

これは、Stateが更新されてPage自体の再描画が行われたとき、handleChange1,2が毎回作り直されていることが原因です。
基本的なことですが処理の内容が一緒でも新しく定義し直したら、それは違う関数になります。

useCallbackを使用して関数の再定義を抑制します。useCallbackを使うと第2引数で設定する依存関係に変更がない限り、関数の再定義を抑制することができます。

import { memo, useCallback, useState } from "react"
import { HeavyComponent } from "../../../components/HeavyComponent"
import { Input, InputProps } from "../../../components/Input"

const InputMemo = memo((props: InputProps) => <Input {...props} />)
const HeavyComponentMemo = memo(() => <HeavyComponent />)

const Page = () => {
  const [input1, setInput1] = useState("")
  const [input2, setInput2] = useState("")

  const handleChange1 = useCallback(
    (e: React.ChangeEvent<HTMLInputElement>) => setInput1(e.target.value),
    []
  )

  const handleChange2 = useCallback(
    (e: React.ChangeEvent<HTMLInputElement>) => setInput2(e.target.value),
    []
  )

  return (
    <div>
      <InputMemo value={input1} onChange={handleChange1} />
      <InputMemo value={input2} onChange={handleChange2} />
      <small>{JSON.stringify({ input1, input2 })}</small>
      <HeavyComponentMemo />
    </div>
  )
}

export default Page

定数

コンポーネントの外で定義されたものは再描画が起きても再定義の対象になりません。定数やStateに依存しないものはコンポーネントの外で定義するのが良いでしょう。

const invokeLog = () => console.log("")

const Page = () => {
...

useRef

まだ、入力しているテキストボックス自体の再描画は行われています。全ての再描画を抑えるためにはuseRefを使用します。
useRefにはDOM操作の他に状態保持の機能があります。インプット要素をrefで制御する場合、入力をしてもStateが更新されないので全ての再描画を抑制することができます。
こちらはサブミット時など、指定したタイミングで値を取り出すことで、入力値を使用できます。逆にリアルタイムに変更を画面で表示するなどには不向きです。

import { memo, useRef, useState } from "react"
import { HeavyComponent } from "../../../components/HeavyComponent"
import { Input, InputProps } from "../../../components/Input"

const InputMemo = memo((props: InputProps) => <Input {...props} />)
const HeavyComponentMemo = memo(() => <HeavyComponent />)

const Page = () => {
  const input1Ref = useRef<HTMLInputElement>(null)
  const input2Ref = useRef<HTMLInputElement>(null)

  const [values, setValues] = useState({ input1: "", input2: "" })

  const submit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault()
    setValues({
      input1: input1Ref.current?.value ?? "",
      input2: input2Ref.current?.value ?? "",
    })
  }

  return (
    <form onSubmit={submit}>
      <InputMemo inputRef={input1Ref} />
      <InputMemo inputRef={input2Ref} />
      <small>{JSON.stringify(values)}</small>
      <HeavyComponentMemo />
      <button>submit</button>
    </form>
  )
}

export default Page

React Hook Form (RHF)

RHFはrefによる制御を行なっています。RHFのregister関数はname、ref、onBlur、onChangeを返しています。
ゆえに、RHFを使用すると意識せずとも再描画が抑制されるので、パフォーマンス遅延が起きづらいです。(素晴らしい👏)

import { memo, useState } from "react"
import { SubmitHandler, useForm, UseFormRegisterReturn } from "react-hook-form"
import { HeavyComponent } from "../../../components/HeavyComponent"
import { Input } from "../../../components/Input"

const HeavyComponentMemo = memo(() => <HeavyComponent />)

type FormValues = {
  input1: string
  input2: string
}

const Page = () => {
  const { register, handleSubmit } = useForm<FormValues>()

  const [state, setState] = useState({ input1: "", input2: "" })

  const submit: SubmitHandler<FormValues> = (data) => setState(data)

  return (
    <form onSubmit={handleSubmit(submit)}>
      <Input {...convert(register("input1"))} />
      <Input {...convert(register("input2"))} />
      <small>{JSON.stringify(state)}</small>
      <HeavyComponentMemo />
      <button>submit</button>
    </form>
  )
}

// Input.tsxではrefの代わりにinputRefを定義しているので、ref->inputRefにセットし直します。
function convert({ ref, ...others }: UseFormRegisterReturn) {
  return { inputRef: ref, ...others }
}

export default Page

メモ化の注意

全てをメモ化するのは悪手です。
メモ化するのにもコストが掛かってきます。ですので描画コストが高いコンポーネントに限り、メモ化するのが良いでしょう。

Source

使用したコードは以下に格納しています。
https://github.com/ishiyama0530/react-form-recipes/tree/main/src/pages/rerendering

まとめ

全てに対してメモ化やrefを使用していると記述が多くなる分、可読性が下がります。
それらを処理するコストも掛かってきます。
どこまでやるか、バランスをとって実装することが大切でしょう。

Discussion