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
用のコールバックを用意するというものもあります。 ↩