251
186

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

意味のない useCallback とその理由と解消法

Last updated at Posted at 2020-05-19

reack-hooksの1機能である useCallback はパフォーマンス改善の文脈でよく登場しますが、どうもその利点や使い方には分かりにくい部分があるように感じます。私も少し前まではまでは「コールバックを子コンポーネントに渡す場合に使ったほうがいいらしい」くらいにしか理解していませんでした。

実際 useCallback は単体で利用してもその効果を発揮しにくく、注意しないと「意味のない useCallback 」が生まれてしまう可能性があります。

本記事ではあえて「意味のない useCallback 」になってしまう例を用意し、その理由の考察と解消を通してuseCallbackの基本的な利用方法を説明します。
(紹介するのはあくまで useCallback 利用の1例になりますが、useCallback の基本的な役割を理解するのに役に立つはずです)

想定読者

useCallback の存在は知っているがどんな時になぜ必要になるのかはイマイチ理解していない方。
(つまり少し前までの私)

結論

最初に本記事の結論を記載します。
(これだけで理解できてしまう方は、恐らく以降を読む必要は無いでしょう。)

  1. コールバックを受け取るコンポーネントは、

    「propsの更新」と「親コンポーネントの再レンダリング」という2つの要因によって、

    不要な再レンダリングが行われる(ことがある)
  2. useCallbackを利用すると(不要な)「propsの更新」は抑制できるが、

    「親コンポーネントの再レンダリング」という要因は残るので、

    結局再レンダリングが起きてしまう。

    (この段階では「意味のないuseCallback」)
  3. React.memo等を組み合わせることで、
    「親コンポーネントのレンダリング」をトリガーとする不要なレンダリングも抑制できる
  4. 2.と3.の合わせ技で、全体として不要なレンダリングを無くすことができる。

    (こうなって初めて「意味のあるuseCallback」に!)

本題: 意味のないuseCallbackとその理由と解消法

ここからが本題。

useCallbackの概要

まずuseCallbackについて、公式ドキュメントの記載を引用します。

メモ化されたコールバックを返します。

インラインのコールバックとそれが依存している値の配列を渡してください。useCallback はそのコールバックをメモ化したものを返し、その関数は依存配列の要素のひとつが変化した場合にのみ変化します。これは、不必要なレンダーを避けるために(例えば shouldComponentUpdate などを使って)参照の同一性を見るよう最適化されたコンポーネントにコールバックを渡す場合に便利です。

うん。よく分かりませんね:relaxed:
でもこれわかりやすく説明するの難しいんですよ。。。
以降一応私なりの説明を入れますが、雰囲気を掴むくらいにして次の具体例に進んでください。(あまり細かい表現にツッコミを入れないように!)

まず最初にざっくり結論を言うと、
「useCallbackを使うとコールバックを不変の値にできる」
と考えておくと良いと思います。

コールバックは大抵コンポーネントの中で宣言すると思いますが、その場合コンポーネントの再レンダリングのたびに、コールバックも再生成されます。このコールバックを子コンポーネントにpropsとして渡す場合、毎回異なるpropsを受け取っていると判断されます(処理内容は同じであるにも関わらず...)。

propsの更新はコンポーネントの再レンダリングの条件です(詳細は後述)。そのためコールバックを受け取ったコンポーネントは不必要に何度も再レンダリングされます。パフォーマンスに悪影響を与える可能性があるためこの動作は望ましくありません。

useCallbackはこれを防ぎます。コンポーネントが何度再レンダリングされても、useCallbackを用いて作成されたコールバックは再生成されず同じ値を返します。その結果コンポーネントのpropsの変更が抑制され、不要な再レンダリングを減らすことができる!というのがuseCallbackに期待するべき役割(の代表例)です。

useCallbackを使いたくなるシーン

具体例をみてみましょう。以下にサンプルAを用意しました。フォームに文字を入力すると、文字の長さを表示する簡単なアプリです。

// サンプルA
const App = () => {
  const [ input, setInput ] = useState("")
  const onChange = (e: React.ChangeEvent<HTMLInputElement>) => setInput(e.target.value)

  return (
    <>
      <InputWithLabel onChange={onChange} />
      <Length input={input} />
    </>
  )
}

const InputWithLabel = (prop: { onChange: (e: React.ChangeEvent<HTMLInputElement>) => void }) => {
  const { onChange } = prop

  console.log('!!!!rendering InputWithLabel!!!!')

  return (
    <>
      <span>Label: </span>
      <input type="text" onChange={onChange}/>
    </>
  )
}

const Length = (props: { input: string }) => (
  <div>length: {props.input.length}</div>
)

入力と表示を別コンポーネント(InputWithLabel & Length)に分けており、InputWithLabelには外からコールバック(onChange)を渡しています。フォームの状態(input)は親コンポーネント(App)に持たせることで、InputWithLabelとLengthの両方から状態へのアクセスが可能なようにしています。

またInputWithLabelはレンダリングされるたびに、ブラウザのコンソール上に「!!!!rendering InputWithLabel!!!!」を表示します。

実際に動かすと以下のようになります。

gif1.gif

フォームに文字を入力する度に、ブラウザのコンソール上で「!!!!rendering InputWithLabel!!!!」が表示されます(2回目以降は左のバッジ内の数字がインクリメントされる)。文字入力の度にInputWithLabelが再レンダリングされていることが分かります。

フォームに文字を入力してもInputWithLabelのpropsやstateは変化しません。そのため本来であればこの再レンダリングは行われて欲しくありません。

しかし一方でAppはstate(= input)が更新されることで再レンダリングが行われ、それに伴いonChangeも再生成されます。InputWithLabelから見ればprops(= onChange)が更新されたことになるので、再レンダリングが実施されます。これは不本意ながら自明な動作です。

意味のないuseCallback

このサンプルコードをuseCallbackを使って改善します。InputWithLabelの意図しないpropsの更新を止めることができれば、不要な再レンダリングを抑制出来るはずです!
(しかしながらこの章のタイトルは「意味のないuseCallback」...)

// サンプルB
const App = () => {
  const [ input, setInput ] = useState("")
  const onChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => setInput(e.target.value), [setInput])

  return (
    <>
      <InputWithLabel onChange={onChange} />
      <Length input={input}/>
    </>
  )
}

修正したのはAppのonChangeだけです。(InputWithLabel, Lengthは割愛しました。)
useCallbackを利用したため、Appが再レンダリングされたとしてもonChangeは再生成されません。結果InputWithLabelのprops(= onChange)は変わらないため、不要な再レンダリングも行われないはずです...!

実際に動かしてみましょう。

gif2.gif

...あれ?結果が変わりません。フォームに入力する度に「!!!!rendering InputWithLabel!!!!」が繰り返し表示されています。おかしい。こんなことは許されない。。。。せっかくuseCallbackを使ったのに効果を発揮していないじゃないですか!:rage:

そう本記事のタイトルの通り、「意味のない useCallback 」になってしまっているのです。

なぜ「意味のないuseCallback」になってしまったか

なぜこんなことになってしまったのでしょうか?
そのためにはまず「Reactコンポーネントの再レンダリング条件」を理解する必要があります。

Reactコンポーネントの再レンダリング条件

Reactコンポーネントの再レンダリングはおおよそ以下3つの条件で発生します。

  1. propsの更新
  2. stateの更新
  3. 親コンポーネントが再レンダリングされた時

詳細は以下のサイトを参考にしてください。(もしかしたら簡単に別記事書くかも。。。)
https://ja.reactjs.org/docs/react-component.html#the-component-lifecycle
https://qiita.com/teradonburi/items/5b8f79d26e1b319ac44f
https://www.kirupa.com/react/avoiding_unnecessary_renders.htm

その上で2点留意事項があります。この後の説明で重要になってきますので覚えておいてください。

  • 3つの条件は重複して発生する
  • 条件を1つでも満たした場合には再レンダリングが起こる

InputWithLabel における再レンダリングの条件(useCallback未使用時)

これを踏まえて話をサンプルA(useCallback未使用)に戻します。
サンプルアプリのフォームに文字を入力した際、InputWithLabel は再レンダリングされました。

その際に満たされた「再レンダリングの条件」は何でしょうか?

// サンプルA(再掲)
const App = () => {
  const [ input, setInput ] = useState("")
  const onChange = (e: React.ChangeEvent<HTMLInputElement>) => setInput(e.target.value)

  return (
    <>
      <InputWithLabel onChange={onChange} />
      <Length input={input} />
    </>
  )
}

const InputWithLabel = (prop: { onChange: (e: React.ChangeEvent<HTMLInputElement>) => void }) => {
  const { onChange } = prop

  console.log('!!!!rendering InputWithLabel!!!!')

  return (
    <>
      <span>Label: </span>
      <input type="text" onChange={onChange}/>
    </>
  )
}

const Length = (props: { input: string }) => (
  <div>length: {props.input.length}</div>
)

正解は
「1. propsの更新」
「3. 親コンポーネントが再レンダリングされた時」
です。

InputWithLabelに渡されるコールバック(onChange)が毎回生成されるため、
「1. propsの更新」が満たされます。

またonChange内で更新されるinputは親コンポーネントのstateなので、
「3. 親コンポーネントが再レンダリングされた時」も満たされます。

つまりコンポーネントの再レンダリング条件は重複して満たされることになります。
もしこの「不要な再レンダリング」を取り除くなら、両方の条件を同時に抑制する必要があります。

InputWithLabel における再レンダリングの条件(useCallback使用時)と、「意味のないuseCallback」になってしまった原因

次は再度サンプルB(useCallback使用)を見てみます。
useCallbackを使った結果、満たされる再レンダリングの条件はどのように変わるでしょうか?

// サンプルB(再掲)
const App = () => {
  const [ input, setInput ] = useState("")
  const onChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => setInput(e.target.value), [setInput])

  return (
    <>
      <InputWithLabel onChange={onChange} />
      <Length input={input}/>
    </>
  )
}

正解は
「1. propsの更新」は抑制される
「3. 親コンポーネントが再レンダリングされた時」はそのまま
です。

useCallbackによってonChangeが不変の値になります。つまりInputWithLabelから見て「1. propsの更新」は抑制されます。

しかしこれだけでは
コールバック内で親コンポーネントの状態が更新されることにより起こる、
「3. 親コンポーネントが再レンダリングされた時」の条件を抑制することができません。

InputWithLabelはAppが再レンダリングされた場合には、props/stateが全く変化していなくても再レンダリングされてしまいます。せっかくuseCallbackを用いて「1. propsの更新」を抑制しても、これでは意味がありません。

「意味のないuseCallback」が生み出されてしまった原因はまさにここにあります。

正しく言えばこの場合のuseCallbackは全く意味がないわけではなく、「1. propsの更新」というコンポーネントの更新条件を抑制しています。

ただ他にもコンポーネントの更新条件を満たしてしまっているため、
結果としてuseCallbackだけでは「不要な再レンダリングを抑制できない」ということになります。

解消法

さてではこの問題をどのように解消すれば良いでしょうか?
話としては簡単です。
「3. 親コンポーネントが再レンダリングされた時」の条件も一緒に抑制してしまえばいいわけです。

ここで出てくるのがReact.memo/PureComponent/shouldComponentUpdateになります。これらを用いることで、「3. 親コンポーネントが再レンダリングされた時」の条件を満たす場合でも、
stateやpropsが更新されていないなら再レンダリングをしない、
という処理が実現できます。
詳細は公式ドキュメント等を参照してください。もしくはググれば参考になる記事がたくさん出てきます!
(そろそろ疲れてきたのでぶん投げ)

では実際にReact.memoを利用してサンプルコードを修正してみます。

const App = () => {
  const [ input, setInput ] = useState("")
  const onChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => setInput(e.target.value), [setInput])

  return (
    <>
      <InputWithLabel onChange={onChange} />
      <Length input={input} />
    </>
  )
}

const InputWithLabel = React.memo((prop: { onChange: (e: React.ChangeEvent<HTMLInputElement>) => void }) => {
  const { onChange } = prop

  console.log('!!!!rendering InputWithLabel!!!!')

  return (
    <>
      <span>Label: </span>
      <input type="text" onChange={onChange}/>
    </>
  )
})

const Length = (props: { input: string }) => <div>length: {props.input.length}</div>

変わったのはInputWithLabelの宣言部分です。元々のコンポーネントをReact.memoでラッピングしています。
では動作を確認してみます。

gif3.gif

素晴らしい! 先ほどまでと異なり「!!!!rendering InputWithLabel!!!!」が表示されません。つまりInputWithLabelは再レンダリングされていないということになります。これでようやく「不要なレンダリング」を抑制できました。やったぜ!

1点重要なのは、React.memoやPureComponentだけではこの「不要なレンダリングの抑制」はできなかったということです。繰り返しになりますが、InputWithLabelが受け取るコールバック(onChange)は、useCallbackを使って宣言されないと、レンダリングの度に再生成されてしまいます。React.memoだけでは不要なpropsの更新は抑制できないため、今度は逆のパターンで不要なレンダリングの抑制に失敗するというわけです。

useCallbackとReact.memoを組み合わせることによって初めて不要なレンダリングを取り除くことができた形になります。

まとめ

本記事ではuseCallbackが効果を発揮するケースとしないケースの違いを通して、useCallbackの基本的な役割について説明しました。

そもそもコールバック関数は親コンポーネントの状態を変更することが多いと思います。つまり「3. 親コンポーネントが再レンダリングされた時」の条件はコールバック関数を使う時点で満たされるケースが多いはずです。すると必然的にReact.memoと組み合わせないとuseCallbackは効果を発揮できなくなります。
(うーん、何度書いてもややこしくて説明しにくい。。。)

この記事を読んだ方のuseCallbackに対する理解が、少しでも深まれば幸いです。
(と言っても私もあまり深く理解しているわけではないですが;)

また少し余談になりますが、Reactコンポーネントにおいて「不要な再レンダリング」が話題になる場合、たいてい「3. 親コンポーネントが再レンダリングされた時」が関連しているのではないかと個人的には思っております。その意味でこの3つ目の条件は重要です。(ある意味当たり前すぎるのか)普段あまり話題に登ることが少ないように感じますが、覚えておいて損はないでしょう。

とか偉そうなことをたくさん言っていますが、自信が無い点も多々あります。
(そもそもの解釈が間違ってたらどうしよう。。。)
これは間違ってるよーという話がありましたら是非ともコメントくださいm(_ _)m

251
186
5

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
251
186

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?