この記事は「Concurrent Mode時代のReact設計論」シリーズの6番目の記事です。
シリーズ一覧
- Concurrent Mode時代のReact設計論 (1) Concurrent Modeにおける非同期処理
- Concurrent Mode時代のReact設計論 (2) useTransitionを活用する
- Concurrent Mode時代のReact設計論 (3) SuspenseやuseTransitionが何を解決するか
- Concurrent Mode時代のReact設計論 (4) コンポーネント設計にサスペンドを組み込む
- Concurrent Mode時代のReact設計論 (5) トランジションを軸に設計する
- Concurrent Mode時代のReact設計論 (6) Concurrent Modeと副作用
- Concurrent Mode時代のReact設計論 (7) ステート管理ライブラリの展望(仮)
- Concurrent Mode時代のReact設計論 (8) まとめ(仮)
Concurrent Modeと副作用
Concurrent Modeと切っても切り離せない関係にあるのが副作用です。ここでの副作用とは、Reactの管轄外の処理全般と思って差し支えありません。例えば、Reactを介さずにDOMを直接操作するのは副作用です。
副作用の中でも特にConcurrent Modeと関係するのは、ページ外部との通信(HTTP通信やWorkerとの通信など)です。これは副作用であると同時に非同期処理でもあり、Concurrent Modeの恩恵を大いに受けられる部分です。
Reactにおける副作用
Reactにおいては、副作用はどこでも発生させてよいわけではありません。副作用を発生させるのに適しているのはuseEffect
フックのコールバック関数の中や、イベントハンドラの中などです。逆に、レンダリング中(関数コンポーネントが呼び出されてから返り値を返すまでの間)に副作用を発生させてはいけません。
これは、例えば関数コンポーネントの呼び出し(=レンダリング)1回がちょうど1回の画面への反映(Webの場合は実際のDOMへの反映)に対応するわけではないからです。これはConcurrent Modeでさらに顕著になりました。Concurrent Modeではコンポーネントのレンダリングがサスペンドする可能性がありますが、サスペンド開けにはコンポーネントが再度レンダリングされます(すなわち、関数コンポーネントならば関数が再度呼び出されます)。こうなると、コンポーネントが1回画面に描画されるまでの間に関数コンポーネントの呼び出しが複数回行われることになります。
例えば「コンポーネントが画面に反映されたタイミングで副作用を発生させる」ようなことを行いたい場合は、関数コンポーネントの中にその処理をベタ書きしてはいけません。代わりにuseEffect
を用いるのが適しています。例えば、コンポーネントが表示されたときにsendTrackingEvent('PageA');
を実行したい場合は次のようにします。
const BadExample = ()=> {
// だめな例(関数コンポーネント内にベタ書き)
sendTrackingEvent('PageA');
// ...
}
const GoodExample = ()=> {
// 良い例(useEffectを使用)
useEffect(()=> {
sendTrackingEvent('PageA');
}, []):
// ...
}
だめな例(BadExample
)のようにしてしまうと、実際にBadExample
の内容が画面に表示されるまでに複数回sendTrackingEvent('PageA');
が実行されてしまう可能性があります。ここでuseEffect
を使えばちょうど1回だけ実行されることが保証されます。
このように、副作用は一般に、行うべきときに然るべき数だけ行うことが重要です。1回だけ起こるべき副作用が複数回起こるのは正しい実装とは言えないでしょう。
逆から見れば、Reactに管理された世界は純粋な(副作用のない)世界です。例えば関数コンポーネントは副作用を発生させないことが期待されています。つまり、関数を呼び出しても(Reactの与り知らぬところで)何も起きないということです。この仮定があるからこそ、Reactは関数コンポーネントを好き勝手に呼び出すことができます。
関数コンポーネントの最も基本的な機能は「propsを引数として受け取って、返り値としてレンダリング結果を返す」というものですが、これは純粋な環境下でレンダリングを行うための設計の結果であると言えます。関数に渡す引数を用意するのも関数の返り値を使うのもReactですから、このインターフェースを守る限り関数コンポーネントは完全にReactの管轄下で動作します。これによってReactにおける関数の純粋性という概念が生じているとも言えますね。
ステート更新と副作用
実は、Reactでは(特にConcurrent Modeでは)ステート更新の際にも副作用が起こらないように注意する必要があります。問題となるのは、関数を用いてステートを更新する場合です。具体的には、setState
では現在のステートに基づいて次のステートを計算する際には関数を用いてステートを更新することが推奨されています。また、useReducer
を使う場合は次のステートはreducerによって計算されますから、必然的に関数を用いてステートを更新していることになります。
前回まで使ってきた例では、Root
コンポーネントはこのようにステート更新を行なっていました。これは関数を用いないステート更新です。
setState({
page: "B",
usersFetcher: new Fetcher(() => fetchUsers())
});
これを関数を用いるように変えてみると、こんな感じになります。
setState(obj => {
return {
...obj,
page: "B",
usersFetcher: new Fetcher(() => fetchUsers())
};
});
今回は意味のない例ですが、実際には関数によるステート更新が必要な場面が多くあるでしょう。最初に宣伝したアプリでも関数によるステート更新が多々行われています。
Concurrnent Modeでは、関数によるステート更新を行なった場合、ステート更新は1回だけ(setState
の呼び出しが1回だけ)なのに関数が複数回呼び出されることがあります。正確な発生条件はまだ解明できていませんが、useTransition
と組み合わせた場合は必然的に発生する場面があります。
例えば、useTransition
を使用してstartTransition
内でステートを更新し、サスペンドが発生した場合は2つのステートが同時に管理されることになります。一つはステート更新前でisPending
がtrue
の世界、もう一つはステート更新後の(サスペンド空けに画面に反映される)世界です。前者の世界でさらに追加でステート更新が行われた場合、後者の世界にもそれが反映されなければいけません。これはちょうどgit cherry-pickのような動作となります。この目的のために、前者の世界で用いられたステート更新関数が後者の世界に対してもう一度使用されたり、あるいはstartTransition
の中で実行されたステート更新が再実行されたりします。
つまるところ、我々が関数を用いてステート更新を行うとき、その関数がちょうど一回実行されるなどという期待をしてはいけないということです。よって、ちょうど一回実行するべき副作用を、ステート更新関数の中で行うことはできません。普通に考えると、これは「ステート更新の中で副作用を発生させない」という原則に行き着きます。すなわち、ステート更新関数にも純粋性が求められているのです。
実際にステート更新関数が再実行されるところを見たいという方のためにRoot
を少し改造した例を用意しておきました。CodeSandboxで走らせてみましょう。
長いので折りたたみ
export const Root: FunctionComponent = () => {
const [state, setState] = useState<AppState>({
page: "A",
count: 0
});
const [startDefaultTransition, isPending] = useTransition();
const goToPageB = (
startTransition: React.TransitionStartFunction = startDefaultTransition
) => {
startTransition(() => {
setState(obj => {
console.log("fetch", obj);
return {
...obj,
page: "B",
usersFetcher: new Fetcher(() => fetchUsers())
};
});
});
};
return (
<Suspense fallback={null}>
<p>
<button
onClick={() => {
setState(state => {
console.log("count", state);
return {
...state,
count: state.count + 1
};
});
// setState({
// ...state,
// count: state.count + 1
// });
}}
>
{state.count}
</button>
</p>
<Page state={state} goToPageB={goToPageB} />
</Suspense>
);
};
const Page: FunctionComponent<{
state: AppState;
goToPageB: (startTransition: React.TransitionStartFunction) => void;
}> = ({ state, goToPageB }) => {
if (state.page === "A") {
return <PageA goToPageB={goToPageB} />;
} else {
return <PageB usersFetcher={state.usersFetcher} />;
}
};
const PageA: FunctionComponent<{
goToPageB: (startTransition: React.TransitionStartFunction) => void;
}> = ({ goToPageB }) => {
const [startTransition, isPending] = useTransition({
timeoutMs: 10000
});
return (
<p>
<button disabled={isPending} onClick={() => goToPageB(startTransition)}>
{isPending ? "Loading..." : "Go to PageB"}
</button>
</p>
);
};
const PageB: FunctionComponent<{
usersFetcher: Fetcher<User[]>;
}> = ({ usersFetcher }) => {
const users = usersFetcher.get();
return (
<ul>
{users.map(({ id, name }) => (
<li key={id}>{name}</li>
))}
</ul>
);
};
データ取得も副作用である
ところで、勘のいい読者の方は、さっきまでに解説した内容が前回までの記事と食い違っていることに気づいたでしょう。
というのも、前回までの記事ではfetchUsers()
のような処理を通して非同期的なデータの取得を行なってきました。これも立派な副作用です。なぜなら、データ取得というのは普通ネットワークリクエストを伴うからです。特に、1回だけで良いリクエストを複数回行うのはアプリの挙動として大きな問題があります。
ということは、fetchUsers()
という呼び出しは副作用なのです。一方で、さっきのコードを見てください。これはステート更新関数の中でfetchUsers
を呼び出していますから、すなわちステート更新関数の中で副作用を発生させていることになります。これは、実際に場合によってはfetchUsers
が複数回呼び出されてしまうという結果に繋がります。
setState(obj => {
return {
...obj,
page: "B",
usersFetcher: new Fetcher(() => fetchUsers())
};
});
ひとつの対策は、これを関数の外に出すことです。
const usersFetcher = new Fetcher(() => fetchUsers())
setState(obj => {
return {
...obj,
page: "B",
usersFetcher
};
});
しかし、これはいつでも有効な手立てではありません。副作用の中身が前のステートに依存している場合があるからです。例えば、fetchUsers
がページングをサポートしたと仮定すると、次のページに進む時はこんなステート遷移をすることになるでしょう。これは関数の外に出すことができません。なぜなら、state
が無いとnextPageNumber
が分からないからです。
setState(state => {
const nextPageNumber = state.pageNumber + 1;
return {
...state,
pageNumber: nextPageNumber,
usersFetcher: new Fetcher(()=> fetchUsers(nextPageNumber))
};
});
ここに来て、ある種の矛盾にぶつかってしまいました。Concurrent Modeにおいては非同期処理(を表すPromise)はステートに入れるものですから、非同期処理を行うということは次のステートを計算する処理の一部であり、このコード例のようにステート更新関数の中でfetchUsers
を呼ぶのは当たり前のことです。
一方で、大抵の非同期処理は副作用でもあるゆえに、ステート計算関数の中で非同期処理を行うことは、ステート更新関数は純粋でなければならないという原則と真っ向から対立しています。果たしてこの矛盾に我々はどう立ち向かえばいいのでしょうか。
メモ化で矛盾を解決する
筆者のお勧めの方法は、ある種のメモ化によって対処することです。いきなりですが、このために便利なクラスを定義します。それはMemoizedCell<T>
です。
MemoizedCell<T>
type Cell<T> = {
readonly value: T;
readonly deps: readonly unknown[];
}
export class MemoizedCell<T> {
private content: Cell<T> | undefined;
public get(calc: () => T, deps: readonly unknown[]): T {
if (this.content !== undefined && arrayShallowEqual(this.content.deps, deps)) {
return this.content.value;
}
const value = calc();
this.content = {
value,
deps
};
return value;
}
}
const arrayShallowEqual = (arr1: readonly unknown[], arr2: readonly unknown[]): boolean => {
if (arr1.length !== arr2.length) {
return false;
}
for (let i = 0; i < arr1.length; i++) {
if (arr1[i] !== arr2[i]) {
return false;
}
}
return true;
}
これはuseMemo
のReact非依存版みたいな便利オブジェクトです。MemoizedCell<T>
の唯一のメソッドget
は、第1引数に与えられた関数を用いてT
型の値を計算して返します。ただし、2回目以降は可能なら前回の値を再利用します。再度計算させるためには、useMemo
と同様に第2引数の配列の中身を変えます。
const cell = new MemoizedCell<number>();
const calc = () => (console.log('calc is called'), 2 ** 53);
// 初回なのでcalcが呼び出される
const num1 = cell.get(calc, [1]);
// 配列の中身が同じなのでcalcは呼び出されない
const num2 = cell.get(calc, [1]);
// 配列の中身が変わったので再度calcが呼び出される
const num3 = cell.get(calc, [2]);
MemoizedCell<T>
をステート更新に活用する
このMemoizedCell
を用いることで、ステート更新関数が再度呼び出されても再度副作用が発生するのを防ぐことができます。具体的には、次のようにすれば良いでしょう。
const cell = new MemoizedCell<Fetcher<User[]>>();
setState(state => {
const nextPageNumber = state.pageNumber + 1;
return {
...state,
pageNumber: nextPageNumber,
usersFetcher: cell.get(()=> new Fetcher(()=> fetchUsers(nextPageNumber)), [nextPageNumber])
};
});
こうすることで、ステート更新関数が何度呼び出されてもMemoizedCell
のメモ化効果によってfetchUsers
が再度呼び出されることはありません。
今回は依存先としてnextPageNumber
を指定しているためこれが変化したら再度副作用が発生しますが、これはむしろ望ましい動作です。なぜなら、nextPageNumber
が変わったら再度読み込まないと、ステートのpageNumber
とusersFetcher
に齟齬が発生してしまうからです。
ここでのポイントは、メモ化という道具を用いることで「必要な場合にだけ副作用を行う」という動作を宣言的な形で実現できていることです。メモ化というのは一見すると前述の矛盾に対する姑息な解決法にも思えますが、その実は非同期処理を宣言的に書くための欠かせない1ピースなのです。
メモ化により、従来は「ユーザーの一覧をネットワークで取得するという非同期処理」を抽象化してFetcher
として扱っていたのが、さらに抽象度を上げて「ユーザーの一覧を何らかの非同期処理で取得する」という形で再定義されたと言えます。副作用という部分はメモ化のレイヤーによって適切に分離され、ステート更新という抽象度においては副作用のことを考えなくてもよくなったのです。
この方法により、「Fetcher
の作成はステートの更新なので更新関数内で行う」ことと「Fetcher
の作成は副作用なので更新関数内で行えない」ということの間で発生した矛盾は「Fetcher
の作成により起こる副作用のみが更新関数の外へ隠蔽されるため、Fetcher
の作成を更新関数の中で行なっても問題ない」という形で解決されることになります。実際、上のコードをみると、副作用(fetchUsers
)のコード自体は更新関数の中にあるものの、それはさらにcell.get
でラップされており、cell
が更新関数の外の住人であることを以って更新関数からは副作用が隠蔽されていると見ることができます。
上のようなコードは、このような本質をよく描き出している一方で、メモ化という生々しい解決策がむき出しで使われているためあまり綺麗な設計には思えないかもしれません。実際、もう少し綺麗にやる方法があるだろうと思います。そして、Concurrent Mode時代にそれを担うのがデータフェッチングライブラリです。
ReactのConcurrent Mode公式ドキュメントを読むと、ステート管理ライブラリの話は全然出てこない一方で、データフェッチングライブラリの話が盛んにされています。当然ながら、Facebookが作っているRelayが推されています。
なぜステート管理ライブラリではなくデータフェッチングなのか、この記事を読んだ方は何となくお分かりでしょう。データフェッチングライブラリの方が、よりConcurrent Modeに適した設計・抽象化を提供できるからなのです。
まとめ
今回は、Concurrent Modeと副作用の関係について議論しました。Reactでは従来からレンダリングやステート更新の際に副作用を発生させないことを推奨しており、副作用のための隔離された領域としてuseEffect
といったものを用意してきました。これはConcurrent Modeでも変わらず、それどころかさらに注意深く副作用を避ける必要があります。
その一方で、非同期処理に副作用は付き物であり、「非同期処理をステートに入れる」というConcurrent Modeのモデルは一見矛盾しています。この矛盾を解消する方法としてこの記事ではメモ化を提案しています。これにより、非同期処理を表すステートとその作成に必要な副作用を分離することができます。
メモ化というのは、ステートと副作用の分離という点で有効に機能しますが、設計としてやや生々しくはあります。より綺麗な設計のために、Concurrent Modeではデータフェッチングライブラリの重要性が上がるだろうというのが筆者の予測です。Concurrent Modeに特化したデータフェッチングライブラリもこの先現れることが期待できますね。
この記事までで、筆者がConcurrent Mode時代のReactアプリ設計について考えたことはおおよそ説明し終わりました。次の記事は、データフェッチングライブラリの陰に隠れてしまったステート管理ライブラリの今後の展望について触れます。
次の記事:鋭意執筆中