【学習メモ】 useReducer の本質:良いパフォーマンスのためのロジックとコンポーネント設計
はじめに
@uhyo_さんが書いた以下の記事を理解するために、原文を自分でも理解できる表現にリライトしたものになります。
useReducer の本質:良いパフォーマンスのためのロジックとコンポーネント設計
この記事はどんな記事?
どのような時にuseReducer
を利用するのが適しているのかを説明した記事。
まとめ(記事の要約)
useReducer
は、state に依存するロジックを、state に非依存な関数オブジェクト(dispatch
)で表現できる。- state に依存するロジックを、state に非依存な関数オブジェクトで表現できることにより、
React.memo
によるパフォーマンス改善を行える。 useReducer
を活かすには、state を1つにまとめることで、ロジックをなるべく reducer に詰め込む。
useReducer
とは
関数コンポーネントで state 管理ができるようになるフック。
state 管理はuseState
とuseReducer
の 2 種類の方法があり、useReducer
は複雑なロジックが絡んだ state を管理するのに適している。
useReducer
の構文
const [currentState, dispatch] = useReducer(reducer, initialState);
引数として渡すのは以下の2つ。
reducer
: 「現在の state」と「アクション」を受け取って「新しい state」を返す関数initialState
: state の初期値
useReducer
の返り値は以下の2つ。
currentState
: state の現在の値dispatch
: アクションを発火する関数
dispatch
にアクションを渡すと、内部でreducer
が呼び出されて新しい state が計算され、コンポーネントが再レンダリングされて新しい state が反映される。
reducer
の例
const reducer = (state, action) => { if (action.type === "increment") { return { count: state.count + 1 }; } else { return { count: state.count - 1 }; } };
受け取ったアクションに応じて、新しい state を返す。
この reducer では以下のアクションに応じた state を返す
{ type: "increment" }
:count
を 1 増やしたstate
を返す{ type: "decrement" }
:count
を 1 減らしたstate
を返す
上記の reducer を以下のようにuseReducer
に渡し、
const [state, dispatch] = useReducer(reducer, { count: 0 });
返り値であるdispatch
に、以下のようにアクションを渡せば、state が更新される。
dispatch({ type: "increment" });
useReducer
を利用することで、state を更新するロジックを reducer の1つに集約できる。
また、state の種類が増えたりロジックが増えたりしても、その操作の窓口がdispatch
という1点に集約される。
子コンポーネントが何かしらのロジックを発火したいときはdispatch
を props で渡すだけでいいし、コンポーネントツリーが大きい場合はコンテキストを用いて子に伝えるのも有効。
useReducer
がパフォーマンス改善につながる例
React アプリのパフォーマンス改善において大きな効果が出やすいのはReact.memo
を活用すること(クラスコンポーネント時代のshouldComponentUpdate
やPureComponent
に相当)。
これを活用してコンポーネントの余計な再レンダリングを避けることが、React アプリの基本的なパフォーマンス・チューニングとなる。
今回は、useReducer
がReact.memo
の利用の助けになる例を見ていく。
初期状態のサンプル
まず、改善前の初期状態を見てみていく。
今回改善をするサンプルは、以下の機能を有している。
- 4 つの入力欄があり、それぞれに数値を入力することができる。
- 入力欄の下には 4 つの数値を合計した値が表示される。
- 入力欄の横にある「check」ボタンを押すと、そのときの数値が合計の何%かを一番下に表示する。
import React, { useState } from "react"; const sum = arr => arr.reduce((acc, cur) => acc + (Number(cur) || 0), 0); const NumberInput = ({ value, onChange, onCheck }) => { return ( <p> <input type="number" value={value} onChange={e => onChange(e.currentTarget.value)} /> <button onClick={onCheck}>check</button> </p> ); }; export default function App() { const [values, setValues] = useState(["0", "0", "0", "0"]); const [message, setMessage] = useState(""); return ( <> {values.map((value, i) => { return ( <NumberInput key={i} value={value} onChange={v => setValues(current => { const result = [...current]; result[i] = v; return result; }) } onCheck={() => { const total = sum(values); const ratio = Number(value) / total; setMessage( `${value}は${total}の${(ratio * 100).toFixed(1)}%です` ); }} /> ); })} <p>合計は{sum(values)}</p> <p>{message}</p> </> ); }
このコードは、ひとつの数値が変更されるたびに全てのNumberInput
に再レンダリングが発生してしまう。
そのため、NumberInput
にReact.memo
を適用して無駄な再レンダリングを減らしたい。
つまり、ひとつの数値が変更されたらそのNumberInput
だけが再レンダリングされて、他のNumberInput
は再レンダリングされないという状態が理想。
React.memo
導入への努力
とりあえず、useState
のままでReact.memo
の導入を試みる。
NumberInput
にReact.memo
を適用して効果を得るためには、自分以外のNumberInput
の入力値が変わっても props の内容が変化しないようにしなければいけない。
現状ではvalue
は問題ないが、onChange
とonCheck
が問題。
<NumberInput // 省略... onChange={v => // 省略... } onCheck={() => { // 省略... }} />
関数をベタ書きしているので、これらの props には毎回異なる関数オブジェクトが作られて渡されている。
そのため、React.memo
は効かない。
毎回異なる関数オブジェクトが作られるのを防ぐために、useCallback
を利用する。
import React, { useState, useCallback } from "react"; const sum = arr => arr.reduce((acc, cur) => acc + (Number(cur) || 0), 0); const NumberInput = ({ value, index, onChange, onCheck }) => { console.log("render NumberInput"); return ( <p> <input type="number" value={value} onChange={e => onChange(index, e.currentTarget.value)} /> <button onClick={onCheck}>check</button> </p> ); }; export default function App() { const [values, setValues] = useState(["0", "0", "0", "0"]); const [message, setMessage] = useState(""); const onChange = useCallback((index, value) => { setValues(values => { const newValues = [...values]; newValues[index] = value; return newValues; }); }, []); const onCheck = useCallback( index => { const total = sum(values); const ratio = Number(values[index]) / total; setMessage( `${values[index]}は${total}の${(ratio * 100).toFixed(1)}%です` ); }, [values] ); return ( <> {values.map((value, i) => { return ( <NumberInput key={i} index={i} value={value} onChange={onChange} onCheck={onCheck} /> ); })} <p>合計は{sum(values)}</p> <p>{message}</p> </> ); }
これで問題は解決できたように見えるが、解決できていない。
useCallback
を利用することで、onChange
は同じ関数オブジェクトを渡すようになったが、以下のonCheck
が問題。
const onCheck = useCallback( index => { const total = sum(values); const ratio = Number(values[index]) / total; setMessage(`${values[index]}は${total}の${(ratio * 100).toFixed(1)}%です`); }, [values] );
onCheck
はuseCallback
の第2引数が[values]
となっているため、values
が変わるたびにonCheck
が再生成される。
onCheck
が再生成されると、InputNumber
が再レンダリングされるので、React.memo
が意味をなしてない(再レンダリングを防げてない)。
そのため、onCheck
がvalues
に依存している状況をどうにかする必要がある。
一方で、onChange
はvalues
を利用しているが、values
に依存していない。
const onChange = useCallback((index, value) => { setValues(values => { const newValues = [...values]; newValues[index] = value; return newValues; }); }, []);
これは、useState
が提供する state 更新関数が、関数による state の更新をサポートしているから。
上記のコードでは、setValues
関数の引数として「現在の state(values
) を受け取って、新しい state を返す関数」を渡している。
そのため、onChange
はvalues
に依存していない。
つまり、onCheck
がmessage
という state を更新するにあたって、それとは別のvalues
という state に依存していることが問題。
これを解消するためには、2つの state を合体させて2つの state にする必要がある。
このような状況に適しているのがuseReducer
。
useReducer
による解決
前述のコードをuseReducer
を利用して書き換えると以下のようになる。
import React, { useReducer } from "react"; const sum = arr => arr.reduce((acc, cur) => acc + (Number(cur) || 0), 0); const reducer = (state, action) => { switch (action.type) { case "input": { const newValues = [...state.values]; newValues[action.index] = action.value; return { ...state, values: newValues }; } case "check": { const total = sum(state.values); const ratio = Number(state.values[action.index]) / total; return { ...state, message: `${state.values[action.index]}は${total}の${( ratio * 100 ).toFixed(1)}%です` }; } default: { return state; } } }; const NumberInput = React.memo(({ value, index, dispatch }) => { console.log("render NumberInput"); return ( <p> <input type="number" value={value} onChange={e => dispatch({ type: "input", index, value: e.currentTarget.value }) } /> <button onClick={() => dispatch({ type: "check", index }) } > check </button> </p> ); }); export default function App() { const [{ values, message }, dispatch] = useReducer(reducer, { values: ["0", "0", "0", "0"], message: "" }); return ( <> {values.map((value, i) => { return ( <NumberInput key={i} index={i} value={value} dispatch={dispatch} /> ); })} <p>合計は{sum(values)}</p> <p>{message}</p> </> ); }
前述のonChange
とonCheck
が担っていたロジックはreducer
内のロジックに置き換わった(reducer
が担うことになった)。
そのため、NumberInput
にはdispatch
を渡すだけになった。
また、useReducer
を利用することで、2つの state が1つの reducer に集約された。
つまり、「values
を見てmessage
を決める」という計算が、「今の state から次の state を計算する」という枠組み(reducer)の中に入ったということになる。
結果として、dispatch
は state がどのような変更をされるのかは知らなくて済むようになった(アクションを送るだけで良くなった)。
dispatch
は state に非依存の関数なので、前述のonCheck
のようにvalues
が更新されても関数は再生成されなくなった。
そして、再生成されないdispatch
を渡すことにより、React.memo
による再レンダリングを防ぐことができるようになった。
ポイントの整理(今回行ったことを振り返る)
今回の最も重要だったことは「state の更新関数を state に非依存にする」ことである。
useState
で state の更新関数を state に非依存にするためには
前述のuseState
を利用したコードでは、onCheck
という関数が state(values
)に依存している関数だったので、再レンダリングを防げなかった。
state の更新関数を state に非依存にするには、「現在の state を受け取って次の state を計算させる」必要がある。
useState
でこれを実現するには、state 更新関数に関数を渡す必要がある。
そのため、、以下のようにするのではなく、
setValues(newValues);
以下のようにする必要がある。
setValues(currentValues => {...; return newValues });
前述のonChange
ではこれができていたが、onCheck
ではできなかったので、再レンダリングを防げなかった。
useReducer
で何をしたのか
これを改善するために、useReducer
を利用して「2 つの state(values
とmessage
)を 1 つに合体させる」ことを行った。
結果として、onCheck
でも、state に依存しない関数(dispatch
関数)による state 更新ができるようになった。
頑張ればuseState
でも同じようなことはできるが、このような複雑な state を扱うにはuseReducer
が適しているので、useReducer
を選択することになる。
ということで、useReducer
を利用すれば、state 更新関数(dispatch
)は自動的に state に非依存になる。
useReducer
のすすめ
このように、useReducer
を用いることで、state 更新関数を state 非依存にすることを強制できる。
実際のアプリ開発においては、以下のような状況になる可能性がある。
- アプリが複雑化するにつれて、ある state と別の state が関わりを持ち始める。
- ある state を更新するときに別の state を見る必要が発生する。
このような状況になったら、useReducer
導入のサインなので、リファクタリングしてuseReducer
を導入する。
なぜuseReducer
が必要なのか
useReducer
を利用することで、state 更新関数が state に非依存になるから- state 更新関数が state に非依存になることで、
React.memo
を活用しやすくなるから
useReducer
とReact.memo
の恩恵を最大限受けるためには
useReducer
とReact.memo
の恩恵を最大限受けるためにはできるだけreducer
にロジックを詰め込むことが重要。
reducer
にロジックを詰め込むには、アプリの状態は何でも state で表現する必要がある。
そのため、state は明示的・宣言的に扱う(手続き的なロジックは書かない)ことが重要である。
また、useReducer
を活かすためには、そのためのコンポーネント設計も重要。
今回、useReducer
を活かすために、NumberInput
がindex
を props で受け取るようにした。
なぜこのようにしたのかと言うと、dispatch
を呼び出して自分のvalue
を更新するためには、自分が何番目かをdispatch
に教える必要があるから。
このように、useReducer
を活かすためにはコンポーネント設計も重要になる。
副作用はどうするのか?
今回の例では、useReducer
によって state をひとつにまとめることで、onCheck
コールバックを state に非依存にすることができた。
もし、「check」ボタンを押すと起こることが、何らかの副作用(HTTP リクエストが発生するなど)だったらどうするのか。
現時点では、副作用は reducer の中に書くべきではないという原則があるので、この記事で使った手を使うことはできないし、現時点では対処法はない。
副作用をどこかのコールバック関数に書いた時点で、その関数が state に依存することとなり、React.memo
によるパフォーマンス改善の妨げとなる。
Redux の本質
上記の問題を解消する手段の1つが Redux である。
Redux を用いた state 管理の場合、Redux ミドルウェアの活用によって、state に依存する副作用ですらdispatch
の中に押し込めることができる。
つまり、副作用ですら state に非依存させることができる。
Redux の本質は React のツリーの外で state を管理してくれることであり、それによりReact 本体のみでは困難な state 非依存性が実現している。
Redux はただ state 管理に関する統一的な方法論を与えるだけでなく、このようなパフォーマンス上のメリットもあるということは覚えておいて損はない。
React 17.x 系の展望
しかし、React 17.x 系(いわゆるConcurrent Modeが導入されると期待されています)ではまた情勢が変わる可能性がある。
端的に言えば、Concurrent Mode においては(主に非同期的な)副作用ですら state 内で管理されるようになり、そのための道具が Suspense である。
Concurrent Mode では副作用と state 管理の概念が大きく様変わりし、Redux などに頼らずともパフォーマンス的に最適な副作用の扱いが達成できる場面が増えると予期される。
まとめ
この記事では、コールバック関数が state に依存する場合に、React.memo
の恩恵を受けられないという問題に対してuseReducer
を用いて対処する方法を示した。
ポイントはstate 更新関数を state 非依存にすることであり、(useState
でもそれは可能なものの)useReducer
はそのような書き方に適している。
記事冒頭のまとめを再掲。
useReducer
は、state に依存するロジックを、state に非依存な関数オブジェクト(dispatch
)で表現できる。- state に依存するロジックを、state に非依存な関数オブジェクトで表現できることにより、
React.memo
によるパフォーマンス改善を行える。 useReducer
を活かすには、state を1つにまとめることで、ロジックをなるべく reducer に詰め込む。
useReducer
の存在価値は単に Redux と同じ書き方ができるというだけではなく、この記事で説明したような本質的な問題を解決するために利用できる。