React Hooksの正式リリース(2019年2月)からそろそろ一年が経とうとしています。Hooksの登場によってReactのコンポーネントは関数コンポーネントが一気に主流になり、クラスコンポーネントが新規に作られる機会は激減しました。
また、React 17.x系ではConcurrent Modeの導入とともにさらに2種類の新フックが追加される見込みであり、いよいよ関数コンポーネントの能力がクラスコンポーネントを真に上回る時代が来ることになります。
この記事では、フックの一種であるuseReducerに焦点を当てて、どのようなときにuseReducerが適しているのかを説明します。究極的には、useReducerによって達成できるパフォーマンス改善があり、ときにはそれがコンポーネント設計にまで影響を与えることを指摘します。
useStateの影に隠れたり、なぜかReduxと比較されたりといまいちぱっとしないuseReducerですが、この記事でその真の魅力を知っていただければ幸いです。
まとめ
-
useReducerは、ステートに依存するロジックをステートに非依存な関数オブジェクト(dispatch)で表現することができる点が本質である。 - このことは
React.memoによるパフォーマンス改善につながる。 -
useReducerを活かすには、ステートを一つにまとめることで、ロジックをなるべくreducerに詰め込む。
背景: useReducerとは
まずは、初心者の方向けにuseReducerの動作を説明します。すでに知っているという方は次の節まで飛ばしても構いません。
useReducerはフックの一種であり、関数コンポーネントのステートを宣言する能力を持ちます。ステートの宣言はuseStateとuseReducerの2種類の方法がありますが、useReducerは複雑なロジックが絡んだステートを宣言するのに適しています。
useReducerは以下のように使います。こちらが用意するのはreducerとinitialStateの2つです。reducerは「現在のステート」と「アクション」を受け取って「新しいステート」を返す関数であり、initialStateはステートの初期値です。
const [currentState, dispatch] = useReducer(reducer, initialState);
useReducerの返り値は2つで、currentStateはステートの現在の値、dispatchはアクションを発火する関数です。dispatchにアクションを渡すと、内部でreducerが呼び出されて新しいステートが計算され、コンポーネントが再レンダリングされて新しいステートが反映されます。
一応簡単な例を示しておきます。まずはreducerの例です。分かりやすさのためにTypeScriptを用いています。
type State = {
count: number
};
type Action = {
type: "increment" | "decrement";
};
const reducer = (state: State, action: Action): State => {
if (action.type === "increment") {
return {
count: state.count + 1
};
} else {
return {
count: state.count - 1
}
}
};
ここではアクションは{ type: "increment" }または{ type: "decrement" }です。見て分かる通り、これはそれぞれ「カウンタを1増やす」操作と「カウンタを1減らす」操作に相当します。このreducerによって管理されるStateは{ count: number }です。つまり、カウンタの数値をひとつ持っているだけのオブジェクトです。この場合type State = numberでも別に構いませんが、今後の拡張性を考えてこの定義にしています。
これはconst [state, dispatch] = useReducer(reducer, { count: 0 })のように使用します。このdispatchを用いて、dispatch({ type: "increment" })とすればステートが変化してカウンタの値が1増えるでしょう。
これがuseReducerの使い方です。useReducerは、ステートの種類が増えたりロジックが増えたりしてもその操作の窓口がdispatchという一点に集約されている点がポイントです。子コンポーネントが何かしらのロジックを発火したいときはdispatchをpropsで渡すだけでいいし、コンポーネントツリーが大きい場合はコンテキストを用いて子に伝えるのも有効でしょう。
useReducerがパフォーマンス改善につながる例
Reactアプリのパフォーマンス改善において大きな効果が出やすいのはReact.memoの活用です(クラスコンポーネント時代のshouldComponentUpdateやPureComponentに相当)。これを活用してコンポーネントの余計な再レンダリングを避けることが、Reactアプリの基本的なパフォーマンス・チューニングです。
この例では、useReducerがReact.memoの利用の助けになる例を示し、丁寧に解説します。
初期状態のサンプル
まず、改善前の初期状態を見てみましょう。以下のCodeSandboxで実際に動作を確かめることができます。初期状態のコードはApp1.tsxに入っています。
今回の題材はこの画像のようなものです。
4つの入力欄があり、それぞれに数値を入力することができます。下には4つの数値を合計した値が表示されます。また、入力欄の横にある「check」ボタンを押すと、そのときの数値が合計の何%かを一番下に表示します。画像は「123」の横のボタンを押したあとの状態です。
一見意味不明な例に見えますが、これは実は筆者が実際に業務で経験した例をかなり単純化したものになっています。
この記事にも初期状態のコードを一気に貼り付けます。記事を読みつつコードを見たいという方は適宜CodeSandboxをご活用ください。記事中でも部分ごとに解説していきますから、ここで全部読む必要はありません。
import React, { useState } from "react";
import { sum } from "./util";
import "./styles.css";
const NumberInput: React.FC<{
value: string;
onChange: (value: string) => void;
onCheck: () => void;
}> = ({ 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 App1() {
const [values, setValues] = useState(["0", "0", "0", "0"]);
const [message, setMessage] = useState("");
return (
<div className="App">
{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>
</div>
);
}
コードの解説
上記のサンプルのコードを少しずつ解説します。
まず、ひとつの入力欄とボタンのセットが、以下に抜粋するNumberInputコンポーネントで表現されています。入力状態は親のApp1コンポーネントが持つvaluesステートに保存されており、NumberInput自体はステートを持っていません。現在の値はvalueとしてpropsを通じて渡されています。これは、「合計を表示する」といったロジックが親コンポーネントにあることから来る必然的な選択です。
const NumberInput: React.FC<{
value: string;
onChange: (value: string) => void;
onCheck: () => void;
}> = ({ value, onChange, onCheck }) => {
return (
<p>
<input
type="number"
value={value}
onChange={e => onChange(e.currentTarget.value)}
/>
<button onClick={onCheck}>check</button>
</p>
);
};
親コンポーネントであるAppは2つの状態を持ちます。以下に示すvaluesとmessageです。
const [values, setValues] = useState(["0", "0", "0", "0"]);
const [message, setMessage] = useState("");
valuesは4つの入力欄の内容が配列で入っています。messageは「check」ボタンを押したときに表示されるメッセージを管理するステートです。数値の入力が想定されていますが、ステートを数値にしてしまうとちょっと扱いづらいフォームになるので生の入力状態は文字列で持っています。あるあるですね。
ステートの更新部分はNumberInputのpropsに渡す関数にベタ書きです。onChangeが呼び出されたら、setValuesを呼び出してi番目の値がvに書き換えた新しい配列を用意してステートを更新します。onCheckも同様に、メッセージを組み立ててsetMessageを呼び出します。
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)}%です`
);
}}
以上のコードの問題点は、レンダリングのパフォーマンス最適化が何も考えられていないことです。ひとつの数値が変更されるたびに全てのNumberInputに再レンダリングが発生してしまいます。
今回のゴールは、NumberInputにReact.memoを適用して無駄な再レンダリングを減らすことです。特に、ひとつの数値が変更されたらそのNumberInputだけが再レンダリングされて、他のNumberInputは再レンダリングされないという状態が理想です。
お察しの通り、最終的にはuseReducerを用いてこれを達成することになります。
React.memo導入への努力
とりあえず、まずはuseStateのまま努力してみましょう。NumberInputにReact.memoを適用して効果を得るためには、他の入力値が変わってもpropsの内容が変化しないようにしなければいけません。現状ではvalueは問題ありませんが、onChangeとonCheckが問題です。あの位置に関数をベタ書きということは、これらのpropsには毎回異なる関数オブジェクトが作られて渡されています。これではReact.memoは効きません。
こういうときの定石はuseCallbackです。とはいえ、今回はループでNumberInputを表示しているのでひと工夫必要です。筋のいい方法としては、NumberInputに「自分が何番目か」を表すpropsを渡すという方法があります1。これをコールバックに渡してもらうことで、onChangeとonCheckは全てのNumberInputからのコールバックをひとつの関数で対応できます。
以上の工夫を導入して得られたのが、上記のCodeSandboxでいうApp2.tsxです。全体像を見たいからはCodeSandboxをご覧ください。
ここでは部分ごとに変更点を見ていきます。まずNumberInputです。
const NumberInput: React.FC<{
value: string;
index: number;
onChange: (index: number, value: string) => void;
onCheck: (index: number) => void;
}> = memo(({ value, index, onChange, onCheck }) => {
return (
<p>
<input
type="number"
value={value}
onChange={e => onChange(index, e.currentTarget.value)}
/>
<button onClick={() => onCheck(index)}>check</button>
</p>
);
});
NumberInputはpropsとしてindexを受け取るようになりました。これが、自身が何番目かを表す数値です。onChangeとonCheckの型も変更され、これらの関数にはindexがオウム返しで渡されるようになっています。先ほども説明した通り、これによりonChangeとonCheckを各NumberInputごとに異なる関数を用意する必要が無くなります。
次に、Appの変更点を見ます。まずレンダリング部分だけ抜粋すると、こうなりました。
return (
<div className="App">
{values.map((value, i) => {
return (
<NumberInput
key={i}
index={i}
value={value}
onChange={onChange}
onCheck={onCheck}
/>
);
})}
<p>合計は{sum(values)}</p>
<p>{message}</p>
</div>
);
NumberInputに渡すpropsにindexが追加されているのに加え、onChangeとonCheckが事前に用意されるようになりました。次に、これらを用意する部分のコードです。
export default function App() {
const [values, setValues] = useState(["0", "0", "0", "0"]);
const [message, setMessage] = useState("");
const onChange = useCallback((index: number, value: string) => {
setValues(values => {
const newValues = [...values];
newValues[index] = value;
return newValues;
});
}, []);
const onCheck = useCallback(
(index: number) => {
const total = sum(values);
const ratio = Number(values[index]) / total;
setMessage(
`${values[index]}は${total}の${(ratio * 100).toFixed(1)}%です`
);
},
[values]
);
return /* 省略 */
}
onChangeとonCheckはuseCallbackに囲まれています。それぞれの関数の中身は、indexを引数で受け取るようになった以外は変わりません。
できることは全部やったように見えますが、残念ながらこのコードはまだ目的を達成できていません。onChangeはuseCallbackにより常に同じ関数オブジェクトになっているのでOKですが、onCheckが問題です。
onCheckはuseCallbackの第二引数が[values]となっています。これは、valuesが変わるたびに、すなわち何か入力が変わるたびに、onCheckが作りなおされるということを意味します。これによりNumberInputに渡されるonCheck関数が毎回別物になるため、React.memoが無意味になっています。
では、なぜuseCallbackの第二引数がvaluesを含んでいなければいけないのでしょうか。それはもちろん、onCheckがvaluesに依存しているからです。つまり、onCheckが中で「入力値の合計」を求めるためにvaluesを使用しているのです。onCheckのインターフェースが(index: number) => voidである、すなわちindexのみを受け取るという関数である以上、valuesというデータについてはonCheckに内包されていなければいけません。これにより、必然的にvaluesが変わるたびにonCheckという関数は別物になります。
一方で、onChangeはvaluesに依存していません。これは、useStateが提供するステート更新関数が、関数によるステートの更新をサポートしているからです。上のコードではsetValues関数の引数として「現在の状態を受け取って次の状態を返す関数」を渡しています。この機能により、onChangeからvaluesへの依存を消しているのです。
となると、onCheckがmessageというステートを更新するにあたって、それとは別のvaluesというステートに依存していることが問題だと分かります。これを解消するためには、2つのステートを合体させて1つのステートにする必要があります。
このような状況に適しているのがuseReducerです。ということで、AppをuseReducerを用いて書き換えることで問題を解決しましょう。(一応、useStateを使っていても2つのステートをまとめて問題を解決することはできますが、その状況でわざわざuseReducerではなくuseStateを使う意味は薄いのでここでは考えません。)
useReducerによる解決
ということで、最終版です。全体像は以下のCodeSandboxのApp3.tsxでご覧ください。
まず、useReducerを使うのでreducerを用意しましょう。今回何気なくTypeScriptを使っているので型定義もちゃんとあります。
type State = {
values: string[];
message: string;
};
type Action =
| {
type: "input";
index: number;
value: string;
}
| {
type: "check";
index: number;
};
const reducer = (state: State, action: 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)}%です`
};
}
}
};
型定義を読むと、Stateはvaluesとmessageをひとつにまとめたオブジェクトであることが分かります。アクションは"input"と"check"の2種類があり、それぞれ前回のコードのonChangeとonCheckに相当するロジックが書かれています。
次にNumberInputのコードです。
const NumberInput: React.FC<{
value: string;
index: number;
dispatch: Dispatch<Action>;
}> = memo(({ value, index, dispatch }) => {
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>
);
});
propsとして受け取るのはvalue, index, dispatchになりました。従来のonChangeとonCheckがひとつにまとまっていますね。それ以外は特に変わっていません。
最後にAppコンポーネントのコードです。ロジックがreducerの中に移ったのでこちらはシンプルになりました。
export default function App() {
const [{ values, message }, dispatch] = useReducer(reducer, {
values: ["0", "0", "0", "0"],
message: ""
});
return (
<div className="App">
{values.map((value, i) => {
return (
<NumberInput key={i} index={i} value={value} dispatch={dispatch} />
);
})}
<p>合計は{sum(values)}</p>
<p>{message}</p>
</div>
);
}
ステートの宣言はuseReducerにより行われています。従来onChangeとonCheckが担っていたロジックはreducerの中に押し込められましたので、ここでは何もせずにただNumberInputにdispatchを渡すだけになっています。
前のコードと比べると、ここに本質的なポイントがあります。それは2つのステートがひとつのuseReducerに押し込められたことにより、「valuesを見てmessageを決める」という計算が「今のステートから次のステートを計算する」という枠組み(reducer)の中に入ったことです。よって、それを呼び出す側であるdispatchはステートに非依存の関数となりました。NumberInputのpropsはindex, value, dispatchだけとなり、自分以外の値が変わっても再レンダリングされることは無くなりました。これで目標達成です。
ポイントの整理
改めてポイントを整理すると、今回の最も重要だったことは「ステートの更新関数をステートに非依存にする」ということでした。useReducerの場合は、更新関数(dispatch)が非依存であることが保証されています。従来のコード(2番目の例)ではonCheckという関数がステート(values)に依存している関数だったのでうまくいきませんでした。
ステートの更新関数をステートに非依存にするには、「現在のステートを受け取って次のステートを計算する」ということを徹底する必要がありました。useStateの場合はステート更新関数に関数を渡すのを徹底することになります。つまりsetValues(newValues)ではなくsetValues(currentValues => {...; return newValues })とするということです。従来のコードではonChangeではこれができていましたが、onCheckではできていませんでした。
これを改善するために今回行なったことは「2つのステート(valuesとmessage)を1つに合体させる」ということです。これにより、onCheckでも関数によるステート更新ができるようになりました。実を言えばuseStateでも頑張ればこれは達成できますが、このような複雑なステートを扱うにはuseReducerが適しているのでここではuseReducerを選択することになります。useReducerを使う場合はステート更新関数(dispatch)は自動的にステートに非依存になります(reducerはそもそも「現在のステートを受け取って次のステートを計算する」というものであるため)。
useReducerのすすめ
このように、useReducerを用いることで、ステート更新関数をステート非依存にすることを強制できます。実際のアプリ開発においては、アプリが複雑化するにつれて、あるステートと別のステートが関わりを持ち始めるかもしれません。もっと具体的に言えば、あるステートを更新するときに別のステートを見る必要が発生するかもしれません。そのときが**useReducer導入のサイン**です。ぜひリファクタリングしてuseReducerを導入しましょう。
なぜuseReducerが必要なのか、この記事を読んだ皆さんはしっかりと説明できることでしょう。ステート更新関数がステートに非依存であることは、React.memoの活用には必須だからです。
また、useReducerとReact.memoの恩恵を最大限受けるためには、できるだけreducerにロジックを詰め込むことが鍵となります。そのためには、アプリの状態は何でもステートで表現することが重要です。言い換えれば、これは手続き的なロジックを書かず、状態は明示的・宣言的に扱うということです。
また、useReducerを活かすためにはそのためのコンポーネント設計も重要です。今回の例では多少天下り的でしたが、NumberInputがindexをpropsで受け取るようにしたという点にこれが表れています。dispatchを呼び出して自分のvalueを更新するためには自分が何番目かをdispatchに教える必要があるからです("input"アクションがindexを含んでいたことを思い出しましょう)。
副作用はどうするのか? あとReduxの話
ところで、今回の例では「check」ボタンを押すと起こることが「別のステートが更新される」でした。なので、useReducerによってステートをひとつにまとめることで、onCheckコールバックをステートに非依存にすることができたのでした。
では、もし「check」ボタンを押すと起こることが何らかの副作用だったらどうするのでしょうか。例えば、押すとHTTPリクエストが発生するとかです。現時点では、副作用はreducerの中に書くべきではないという原則がありますから、この記事で使った手を使うことはできません。
残念なことに、現時点では対処法はありません。副作用をどこかのコールバック関数に書いた時点で、その関数がステートに依存することとなり、React.memoによるパフォーマンス改善の妨げになります。
実は、これに対する一つの解がReduxの使用です。Reduxを用いたステート管理の場合、Reduxミドルウェアの活用によって、ステートに依存する副作用ですらdispatchの中に押し込めてステート非依存性を達成できてしまうのです。Reduxの本質はReactのツリーの外でステートを管理してくれることであり、それによりReact本体のみでは困難なステート非依存性が実現しているのです。Reduxはただステート管理に関する統一的な方法論を与えるだけでなく、このようなパフォーマンス上のメリットもあるということは覚えておいて損はないでしょう。
React 17.x 系の展望
しかし、React 17.x系(いわゆるConcurrent Modeが導入されると期待されています)ではまた情勢が変わると筆者は期待しています。端的に言えば、Concurrent Modeにおいては(主に非同期的な)副作用ですらステート内で管理されるようになるでしょう。そのための道具がSuspenseです。詳細はそのうち別の記事でお届けしようと思いますが、Concurrent Modeでは副作用とステート管理の概念が大きく様変わりし、Reduxなどに頼らずともパフォーマンス的に最適な副作用の扱いが達成できる場面が増えると予期されます。
useRefに関する注意
ところで、「コールバック関数がステートに依存するのが問題」ということであれば、useRefで解決できると思った方も多いでしょう。実際、以下のようにすればonCheckをvaluesに非依存にすることができます。
const [values, setValues] = useState(["0", "0", "0", "0"]);
const [message, setMessage] = useState("");
const valuesRef = useRef<string[]>([]);
valuesRef.current = values;
const onCheck = useCallback((index: number) => {
const values = valuesRef.current;
const total = sum(values);
const ratio = Number(values[index]) / total;
setMessage(`${values[index]}は${total}の${(ratio * 100).toFixed(1)}%です`);
}, []);
この例ではvaluesの値はつねにvaluesRef.currentに反映され、onCheckはvaluesを参照するかわりにvaluesRef.currentを参照するようにしています。useRefが返すvaluesRefは常に同じオブジェクトであることが保証されていますから、onCheckはvaluesRefに依存することはありません。この方法でもReact.memoを活用するという目的は達成できています。
しかし、筆者はこの方法はお勧めしません。なぜなら、このようにuseRefを使うのはReact 17.x系でうまく動作しなくなる可能性があるからです。Concurrent Modeにおいては、refへの書き込みはもはや副作用と見なされます。関数コンポーネントの処理中にこのようにrefへの書き込みを行うのは思わぬ動作を引き起こす可能性があるのです(特にレンダリングが中断される場合)。
このことは実はReactの公式ドキュメントにも明記されています。「将来的にはより使いやすい代替手段を提供することを計画しています」とありますので、React 17.x系ではよりよい別の手段が提供されるかもしれません。
まとめ
この記事では、コールバック関数がステートに依存する場合に、React.memoの恩恵を受けられないという問題に対してuseReducerを用いて対処する方法を示しました。ポイントはステート更新関数をステート非依存にすることであり、(useStateでもそれは可能なものの)useReducerはそのような書き方に適しています。
記事冒頭のまとめを再掲しておきます。
-
useReducerは、ステートに依存するロジックをステートに非依存な関数オブジェクト(dispatch)で表現することができる点が本質である。 - このことは
React.memoによるパフォーマンス改善につながる。 -
useReducerを活かすには、ステートを一つにまとめることで、ロジックをなるべくreducerに詰め込む。
useReducerはreducerを用いてステート更新を記述できるものでしたが、reducerの存在価値は単にReduxと同じ書き方ができるというだけではありません。useReducerはこの記事で説明したような本質的な問題を解決するための優れた道具なのです。
useStateに比べると使い方がややこしいので尻込みしてしまうかもしれませんが、useStateを多く並べれば並べるほど、いざ必要になったときのリファクタリングが難しくなります。時期を見極めてuseReducerを導入しましょう。
-
ややアクロバットな別解として、
useMemoを用いて各NumberInput用のコールバックを用意するというものもあります。 ↩
