2021-10-01

React Hooksを再確認する2

前回SmartHR-UIのコードを追ってみたが、自分のHooksの記憶が曖昧過ぎたので、もう一度最低限のコードを書きつつHooksを再確認していく

去年7月に確認した記事はこちら
useStateとuseEffectとuseReducerは↑で確認しているので、この記事では触れない
useReducerについてはここでも触れている

この記事ではuseRef, useCallback, useMemo, ReactMemo, useContextに触れる

便利なので最近はpureな環境を試すだけならcodesandboxを使っている

useRef

useRefはrefオブジェクト(React.createRefの戻り値)を返すフックのこと
refオブジェクトを利用することで、DOMの参照や、コンポーネント内でuseStateと同じように値を保持することができる
ただし、useStateと異なり、useRefで生成した値を更新してもコンポーネントの再レンダーは行われない点に注意が必要

従って、「コンポーネント内部で値を保持したいが、値を更新してもコンポーネントを再レンダーしたくない時」に利用する

構文サンプル

// useRefの引数に渡した値がrefオブジェクトのcurrentプロパティの値になる
const refObj = useRef(0)
console.log(refObj.current) // => 0

// currentプロパティの更新
// 更新してもコンポーネントは再レンダーされない
refObj.current = refObj.current + 100

refオブジェクトはコンポーネントが存在する限り存在し続けるので、コンポーネントが再レンダーされた後でもcurrentプロパティの値は保持されている
サンプルコードを書いたので動かしてみて欲しい
useStateで生成した値を更新して、コンポーネントが再レンダーされた後もuseRefで生成した値は保持されていることがわかる

DOMを参照する

useRefではDOMを参照させることができ、作成したrefオブジェクトをHTML要素のref属性に指定すればOK↓

const htmlEL = useRef(null)
<input ref={htmlEL} />

しょうもないサンプルだけど、ボタンを押すとインプットにフォーカスさせるサンプルコードを書いた

React.memo

React.memoはコンポーネントのレンダー結果をメモ化するAPI(キャッシュのようなものだと思えばよい)
メモ化されたコンポーネントは条件を満たさない限り再レンダーされなくなるため

  • レンダーコストが高いコンポーネント
  • 頻繁にレンダーされるコンポーネントの子コンポーネント

などに適用することで、パフォーマンスの向上が期待できる(逆に↑に該当しないコンポーネントに使っても大きな効果はない)
使い方は以下の通りで、関数コンポーネントをReact.memoの引数に渡すだけ

const Hoge = React.memo(({ val }) => {
  return <div>{val}</div>
})

<Hoge val={val} />

これで、コンポーネントに渡されるprops(val)に変化がない限り、コンポーネントは再レンダーされない
こちらもサンプルコードを書いた
メモ化したコンポーネントが依存するpropsが変更にならない限り、再レンダーされていないことがわかる

React.memoの注意点

それは「普通に定義した関数をpropsとして受け取った場合は、常に必ず再レンダーされる」という点
これもサンプルコードを書いたのでそちらを見て欲しい

親のAppコンポーネントで定義した関数を、メモ化した子コンポーネントに渡しているが、親が再レンダーされると子も一緒に再レンダーされていることが確認できる
これは、親のコンポーネントが再レンダーされると、関数も一緒に再生成されるため、同じものとみなされない
判定は↓と同じということだ

const a = () => 'aaa'
const b = () => 'aaa'
const c = a

a === b // false
a === c // true

要するに参照が全く同じでなければ同じ関数だとみなされないということ
しかし、関数を渡したコンポーネントもメモ化されないと困る場合は普通にあるので、そんな時に使うのがuseCallbackである

なお、関数コンポーネント内で、React.memoを行なってもメモ化できない
関数コンポーネント内でコンポーネントをメモ化したい場合は、useMemoを使ってメモ化する(後述)

useCallback

useCallbackを使うことで関数もメモ化することができる
メモ化した関数をメモ化したコンポーネントに渡すことで、再レンダーをスキップできる
構文は以下の通りで、useEffectのように依存配列を指定することができる

useCallback((num) => console.log(num), [num])

依存している値が更新されると関数が再生成される(↑の場合はnum)
依存配列が空の場合は一度だけ生成されて、その後は更新されない
こちらもサンプルコードを書いた

メモ化したHoge子コンポーネントがあり、それにuseCallbackでメモ化した関数をpropsとして渡している
メモ化した関数が依存しないstateが更新されてAppコンポーネントが再レンダーされても、関数は再生成されず、メモ化したHoge子コンポーネントも再レンダーされていないことがわかる
逆に、メモ化した関数が依存しているstateが更新されると、関数は再生成され、メモ化された子コンポーネントも再レンダリングされている

余談だが、useDispatchで作成するdispatch関数は、コンポーネントが再レンダーされても同一性が保たれるため、メモ化したコンポーネントにそのまま渡しても問題ない

useCallbackの注意点

以下のような場合は再レンダーが走るので注意が必要

  • React.memoでメモ化していないコンポーネントに、メモ化した関数をpropsとして渡したとき
  • メモ化した関数を、それを定義したコンポーネント自身で利用したとき

useMemo

useMemoは 関数の結果をメモ化する フック
useCallbackは 関数そのものが対象 なので紛らわしい

以下は構文のサンプル
受け取ったnumを二倍にする関数で、引数のnumに変化がない限り再計算されず、前回の計算結果を返し続ける
(戻り値は計算結果で、関数の参照ではないので注意)

useMemo((num) => num * 2, [num])

コンポーネントのレンダー結果をメモ化する

↑で「React.memoをコンポーネント内で実行してもメモ化されないため、代わりにuseMemoを使う」と書いたが、それも実際に試してみる

サンプルコードつ https://codesandbox.io/s/wizardly-mirzakhani-xbo77?file=/src/App.js

  • 依存していないstateが更新されてコンポーネントが再レンダーされても、再計算(関数の再実行)されず結果を返していること(計算結果に対するuseMemoの適用)
  • 依存していないstateが更新されてコンポーネントが再レンダーされても、メモ化したコンポーネントは再レンダーされていないこと(コンポーネントのレンダー結果に対するuseMemoの適用)
  • コンポーネント内部でReact.memoしているコンポーネントは、それを内包しているコンポーネントが再レンダーされると一緒に再レンダーされる

あと微妙に勘違いしていたけど、通常のコンポーネントと異なり、useMemoでメモ化したコンポーネントは <Hoge> ではなく、 {Hoge} でレンダーする

useContext

useContextはContextというReactの仕組みを利用するために使うフック
Reactのコンポーネントツリーの範囲でグローバルとみなせるデータを使えるようにする
propsのバケツリレーを解消することができるなどの利点があるが、コンポーネントの再利用性を著しく損なうため慎重に利用すべきフック

まず、ReactにおけるContextとはどういう意味なのか整理する
ネットの解説記事を眺めていると、以下のような文脈で使われているっぽい

  1. Propsを利用せずに様々な階層のコンポーネントで値を共有するReactの仕組み
  2. Propsを利用せずに様々な階層のコンポーネントで値を共有するReactのAPI(Context APIとかそういう感じで呼ばれる)
  3. Contextオブジェクト
  4. Contextオブジェクトの値のこと

ここでは1の意味でContextを使っている

Contextを利用するためには以下の3つが必要となる

  • Contextオブジェクト
  • Provider
  • Consumer

一つずつみていく

Contextオブジェクト

React.createContextというAPIを使って生成するオブジェクト
const Hoge = React.createContext() のように作成するとHogeがContextオブジェクトとなり、このHogeが保持している値をProviderを使ってコンポーネントツリーで共有することができる

Provider

Contextオブジェクトが保持しているコンポーネントのこと
Contextオブジェクトに対して .Provider でアクセスできるものがそれに当たる
かなり端折るが以下のような感じ

const Hoge = React.createContext() // Contextオブジェクト
const val = 'hogehoge' // ただの文字列

// Provider
// valueプロパティの値をラップした範囲で共有する
<Hoge.Provider value={val}>
  <Child />
</Hoge.Provider>

Contextオブジェクトが保持しているのはHoge.Providerのvalueの値で、これがpropsを使わないで共有できる値となる
共有した値はConsumerを利用することで取得することができる

Consumer

Contextオブジェクトから値を取得しているコンポーネントのこと
例えば↑のProviderのサンプルでは、Childコンポーネントがラップされているので、以下のようにChildで共有された値を取得していたとする

const Child = () => {
  // HogeはContextオブジェクトで、useContextで値を取得している
  // valはhogehogeという文字列が取得される
  const val = useContext(Hoge)
  return <div>{val}</div>
}

この場合「Contextオブジェクトから値を取得している」ことになるのでChildコンポーネントはConsumerであると言える
ということで、useContextは「Contextオブジェクトから値を取得する」ためのフックである

これもシンプルなサンプルコードを書いた
propsを経由せず、useContextで深い階層でダイレクトに値を取得することができている

Context利用時の注意点

Contextは「Provider内の全てのConsumerは、Providerのvalueプロパティが更新される度に再レンダーされる」という挙動となるので、使い方によってはパフォーマンスの問題を発生させる可能性がある
(ConsumerやConsumerのメモ化していない子コンポーネントの描画コストが高い場合など)

パフォーマンスに問題がある場合は以下のような方法を検討するとよい

  • Contextを分割して、再レンダーされる範囲を小さくする
  • React.memoやuseMemoを使ってメモ化する