React v18 には多くの改善や新機能が盛り込まれる予定だが、そのなかでも特に注目を集めると思われるのが、Concurrent Features と呼ばれる一連の機能。
これらの機能を使うことで、コンポーネントのレンダリングについてより柔軟な設定が可能になり、上手く使えばパフォーマンスや UX の向上を実現できる。
この記事では Concurrent Features のひとつであるstartTransition
と、それを使いこなす上で重要な概念である「トランジション」について説明する。
この記事ではコンセプトの説明や具体例の提示のみを行う。詳細を知りたい場合は以下を参照。
一年前の記事であるため古くなっている部分もあるが、根幹は大きく変わっていないと認識している。
なお、上記の記事には「Concurrent Mode」という用語がタイトルに入っているが、これは今後は使われなくなっていくと思われる。
v18 へのアップグレードをよりスムーズに行えるようにするため、アプローチを変えたとのこと。
我々は段階的な導入に向けてのアップグレード戦略を再設計しました。イチかゼロかの「モード」の代わりに、並行レンダリングは新機能のどれかを利用するような更新がある場合にのみ有効化されるようになりました。実用上、これはつまり書き換えをせずに React 18 を導入し、自分のペースで React 18 の新機能を試していけるようになるということです。
動作確認に使用したライブラリのバージョンは以下の通り。
- next@12.0.7
- react@18.0.0-beta-24dd07bd2-20211208
- react-dom@18.0.0-beta-24dd07bd2-20211208
- typescript@4.5.4
- @types/react@17.0.38
- @types/react-dom@17.0.11
React v18 はまだ正式リリースされていないので、ベータ版を使う。
トランジションとは何か
React は原則的に、データと UI が一対一になっている。データと UI はシンクロしており、データが変化すればそれに対応した UI が新しく作られる。
「状態」もデータの一部なので、「状態変化」によっても、UI の構築は行われる。
以下の例では、ボタンを押す毎にstate
がインクリメントされるため、その度に UI が作られ、表示されている数字も増えていく。
import { useState } from "react"; function App() { const [state, setState] = useState(1); return ( <> <button onClick={() => { setState((s) => s + 1); }} > count up </button>{" "} <span>{state}</span> </> ); } export default App;
状態が更新される度にレンダリングが行われ、最新の状態と対になる UI が作られる。これが React の原則である。
上記の例では状態はひとつだけ(state
)だったが、現実の React アプリでは複数存在することが多い。
そして、ひとつのイベントによって複数の状態が更新されることも珍しくない。その場合 React は、ひとつずつ UI に反映させるのではなく、全ての状態更新が反映された UI を作る。
例えば、何らかのイベントによってA
とB
という二つの状態が更新された場合、まず最新のA
を反映した UI を作り、続いて最新のB
も反映した UI を作る、のではなく、最新のA
とB
が反映された UI を一度に作る。
これはA
とB
を同列に扱うということだが、言い換えれば、これまでの React では、レンダリングに対して優先度をつけることが出来なかったのである。
A
についてはすぐに UI に反映させたいからまずはA
を優先的にレンダリングする、ということができない。このため、何らかの理由でB
のレンダリングに時間がかかってしまう場合、それが終わるまでA
の更新も UI には反映されなくなってしまう。例えA
の反映そのものはすぐに終わるものだったとしても。
トランジションはこの問題を解決する。
トランジションとは「優先度が低い状態更新」のことであり、どの状態更新がトランジションであるかを指定する形で、レンダリングに優先順位をつけることができる。
React アプリの開発者は、startTransition
を使うことで、「この状態更新はトランジションです」と React に指示を出せるようになるのである。
これを上手く使うことで、アプリの操作性を大幅に改善できる可能性がある。
ここからは、具体的な例を示しながらstartTransition
の使い方を説明していく。
環境のセットアップ
サンプルアプリを作るための環境構築を行っていく。簡便なので今回は Next.js を使っているが、startTransition
を使う上で Next.js は必須ではない。
まずは Next.js の環境を構築する。
$ yarn create next-app --ts
次に、React のベータ版をインストールする。TypeScript で開発するので型もインストールする。
$ yarn add react@beta react-dom@beta $ yarn add -D @types/react @types/react-dom
最後に、tsconfig.json
を編集する。
"resolveJsonModule": true, "isolatedModules": true, "jsx": "preserve", - "incremental": true + "incremental": true, + "types": ["react/next", "react-dom/next"] }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], "exclude": ["node_modules"]
Next.js の v12 ではこれだけでstartTransition
を使えるようになる。
サンプルアプリの作成
サンプルアプリとして、メンバー毎のタスクを表示するアプリを開発する。
まずはメンバーとタスクの型を定義する。
type Member = "Alice" | "Bob" | "Carol"; type Task = { id: number; assignee: Member; title: string; description: string; };
続いてタスクリストを作成。
const allTasks: Task[] = [ { id: 1, assignee: "Alice", title: "React 学習", description: "v18 の機能についてキャッチアップする", }, // 同じ要領で適当にタスクを作っていく ]
あとは、これらを元にして UI を作っていく。
重要な箇所は適宜説明していくので、一旦読み飛ばしても構わない。
function TaskCard(task: Task) { const { assignee, title, description } = task; const getBorderColor = (member: Member): string => { if (member === "Alice") return "aqua"; if (member === "Bob") return "lime"; return "orange"; }; return ( <div style={{ border: `5px solid ${getBorderColor(assignee)}`, borderRadius: "18px", margin: "20px", padding: "10px", width: "200px", }} > <div style={{ fontSize: "22px" }}>{title}</div> <div style={{ fontSize: "14px", fontWeight: "bold" }}>{assignee}</div> <hr /> <div style={{ fontSize: "14px" }}>{description}</div> </div> ); } function TaskCardList({ taskList }: { taskList: Task[] }) { return taskList.length > 0 ? ( <ul style={{ listStyleType: "none" }}> {taskList.map((task) => { return ( <li key={task.id}> <TaskCard {...task} /> </li> ); })} </ul> ) : null; } function App() { const [selectedMember, setSelectedMember] = useState<Member>(); const [headline, setHeadline] = useState<string>(""); const [taskList, setTaskList] = useState<Task[]>([]); const members: Member[] = ["Alice", "Bob", "Carol"]; const handleChange = (e: ChangeEvent<HTMLInputElement>) => { const member = e.currentTarget.value as Member; setSelectedMember(member); setHeadline(`${member}'s task`); setTaskList(() => { return allTasks.filter((t) => t.assignee === member); }); }; return ( <> {members.map((m) => { return ( <Fragment key={m}> <input type="radio" value={m} onChange={handleChange} checked={m === selectedMember} /> {m}{" "} </Fragment> ); })} <h1>{headline}</h1> <TaskCardList taskList={taskList} /> </> ); } export default App;
問題なく動いている。
応答性の悪さは UX を大きく損なう
快適に動いているこのアプリだったが、何らかの理由でタスク一覧の表示に時間がかかるようになってしまったとする。
アプリが複雑になって処理が重くなったのかもしれないし、通信している API サーバが遅くなってしまったのかもしれない。ユーザーが使っている端末の性能が高くない場合にも、同様の問題は起こり得る。
その状況を擬似的に再現するため、自作のsleep
関数で処理を遅延させてみる。
function sleep(ms: number) { const startTime = performance.now(); while (performance.now() - startTime < ms); }
const handleChange = (e: ChangeEvent<HTMLInputElement>) => { const member = e.currentTarget.value as Member; setSelectedMember(member); setHeadline(`${member}'s task`); setTaskList(() => { sleep(1500); // これを追加 return allTasks.filter((t) => t.assignee === member); }); };
このようにして改めて触ってみると、操作性が大幅に悪化していることが分かる。
表示速度が遅いこと自体が望ましくないのだが、より大きな問題は、ユーザーがラジオボタンをクリックしたときにアプリが何も反応しないことである。
ユーザーに対するフィードバックが何もないため、自分が操作を間違ったのか、アプリが固まってしまったのか、単に処理に時間が掛かっているだけなのか、ユーザーからは何も分からない。これは大きなストレスになるし、お世辞にも使いやすいアプリとは言い難い。
ウェブアプリのパフォーマンスを考えるときは、絶対的な速度だけでなく、ユーザーがどのように感じるのか、ユーザーが快適に操作できるかも考慮しなければならないが、このアプリではそれが出来ていない。
トランジションによる解決
トランジションによって、この問題をある程度改善できる。
ラジオボタンをクリックしたとき、以下の 3 つの状態が変化している。
- selectedMember
- headline
- taskList
このうち、処理に時間が掛かっているのはtaskList
のみである。他の 2 つは、状態更新そのものも、それに伴うレンダリングも、すぐに行える。
にも関わらず全てのレンダリングをまとめて行おうとするため、taskList
に関する処理が終わるまで UI を全く更新できなくなってしまうのである。
既に述べたように、トランジションでは状態更新に優先順位をつけることができる。
今回の例だと、ラジオボタンが選択されたことはすぐに UI に反映させたいため、selectedMember
は優先度が高い。
また、headline
も処理に時間が掛かっているわけではないので、すぐに反映させることにする。
そして処理に時間が掛かっており、相対的に見て優先度や緊急性が低いtaskList
の状態更新を、トランジションとして扱うことにする。
まず、startTransition
をインポートする。
import { Fragment, useState, startTransition } from "react";
そして、startTransition
でsetTaskList
をラップする。
これにより、setTaskList
はトランジションであると、React に伝えることができる。
const handleChange = (e: ChangeEvent<HTMLInputElement>) => { const member = e.currentTarget.value as Member; setSelectedMember(member); setHeadline(`${member}'s task`); // startTransition で setTaskList をラップする startTransition(() => { setTaskList(() => { sleep(1500); return allTasks.filter((t) => t.assignee === member); }); }); };
この状態で再びサンプルアプリを触ってみる。
ユーザーに操作に応じてラジオボタンと見出しが即座に更新されるようになった。
useTransition を使ったさらなる改善
だがまだ気になる点がある。メンバーを切り替えた際に、古いメンバーのタスクリストが表示され続けてしまっている。
これを改善し、選択したメンバーのタスクリストはまだ読込中であることをユーザーに伝えることができれば、より親切である。
useTransition
を使うことで、それを実現できる。
まず、startTransition
ではなくuseTransition
をインポートする。
import { Fragment, useState, useTransition } from "react";
名前から類推できるようにuseTransition
は Hooks なのだが、返り値の最初の要素には、isPending
と呼ばれる真偽値が入っている。
次の要素はstartTransition
なので、これはそのまま使えばいい。
isPending
は、トランジションがレンダリングされている間はtrue
になり、レンダリングが終わるとfalse
になる。
これをTaskCardList
に渡すことで、読み込み中であることをユーザーに伝えることができる。
function App() { const [selectedMember, setSelectedMember] = useState<Member>(); const [headline, setHeadline] = useState<string>(""); const [taskList, setTaskList] = useState<Task[]>([]); const [isPending, startTransition] = useTransition(); // これを追加 // 中略 <TaskCardList taskList={taskList} isPending={isPending} /> // isPending を渡す
// isPending を受け取るようにした function TaskCardList({ taskList, isPending, }: { taskList: Task[]; isPending: boolean; }) { if (isPending) return <div>Loading...</div>; // ペンディング中はその旨を表示する return taskList.length > 0 ? ( <ul style={{ listStyleType: "none" }}> {taskList.map((task) => { return ( <li key={task.id}> <TaskCard {...task} /> </li> ); })} </ul> ) : null; }