はじめに
都内の企業でモバイルアプリ開発を行っているすずきと申します。早いもので、エンジニアになって半年経ちました。
最近、開発アプリのある画面でAPIから最新のデータを都度取得するような実装を行いました。データ取得中の画面として以下のようなインジケータを追加したのですが、テストで何度か挙動を確認したところ、画面遷移のたびに表示されて軽くストレスを感じました。
UXの質が落ちてるな...と感じたので、YouTubeアプリみたいな感じで画面遷移中も前の画面を残す(インターフェースプレビュー)ようにできないかなぁと調べたのですが、Reactの性質上、そのような実装は難しいということがわかりました。
Reactにも弱点はあるんだな...と思いながらReactの公式ドキュメントを眺めていたところ、Concurrent Modeというページをふとみつけました。
Concurrent Mode(並列モード)
公式Docsを読みすすめてみると、どうやらConcurrent Modeを使うとReactで中断可能なレンダリングが可能になるとのことでした。
更新のレンダーを一度始めたら中断することはできませんよ!というのが、ReactのようなUIライブラリの動作の基本だったのですが、Concurrent Modeだと中断できるようになったということですね。
前の画面を描画させながら、更新後の画面のレンダーをメモリ内で継続し、データが到着するごとに再レンダーを行うことができるので、この仕組みを利用することで、前の画面を表示したまま、インジケータをインライン表示するよう React に伝えることができます。新しい画面の準備が完了したら、React が自動的にそちらを表示します。
SuspenseとuseTransition
Concurrent Modeでインターフェースプレビューを実現するためにキーワードとなるのが、SuspenseとuseTransitionです。
Suspense
SuspenseはReact 16.6で追加されたコンポーネントです。コンポーネントが読みだそうとしているデータが準備できていないとき、宣言的にロード中の状態を指定することができるようになります。
Suspenseを使用する目的の1つに、RESTやGraphQLのデータ取得ライブラリとReactとの深い連携があります。データ取得ライブラリがSuspenseをサポートすることで、コンポーネントからそれを自然に扱えるようになります。Suspenseは連携するライブラリの種類を選びませんが、Facebookで本番環境として利用しているライブラリはRelayのみだそうです(Apolloでは今のところテストされていないようです...)。
useTransition
新しい画面に遷移する前に新しいコンテンツがロードされるのを待機するために使用するのがReact Hooksの1つであるuseTransitionです。
const [startTransition, isPending] = useTransition({
timeoutMs: 3000
});
startTransitionでどのstateの更新を遅延させたいのかをReactに伝え、isPendingでトランジションが進行中かどうかを伝えます。
timeoutMsプロパティでトランジションが終了するまでどれだけ待てるかを指定します。例えば、{timeoutMs: 3000}で、「新しい画面がロードされるのに3秒以上かかったら大きなスピナーを表示せよ、それまでは前の画面を表示しつづけて構わない」ということを伝えています。
データ取得アプローチの比較
インターフェースプレビューの実装が今回の目的なのですが、勉強のためにReactのデータ取得の各種アプローチについて検証してみることにしました。公式Docsの例をベースに、Material-UIの勉強も兼ねて新しく実装しました。
プロフィール部分とグラフ部分がどのような順番で表示されるのかどうかを検証します。ソースはGitHubにあります。
1. Fetch-on-Render(Suspense不使用)
Reactアプリケーションのデータ取得は、classコンポーネントであればライフサイクルメソッド(component~)、functionalコンポーネントであれば副作用(effect)を用いるのが一般的です。
Fetch-on-Renderというのは、このようなレンダー後にデータ取得が始まるようなアプローチのことをいいます。
FetchOnRender.jsx
export const FetchOnRender = () => {
const [userData, setUserData] = useState(null);
useEffect(() => { // ①, ③
fetchUserData().then((u) => setUserData(u));
}, []);
if (userData === null) { // ②
return (
<Typography variant="p" component="h7">
Loading profile...
</Typography>
);
}
return (
<div>
<DetailsContent
company={userData.data.company}
name={userData.data.name}
image={userData.data.image}
/>
<ProfileChart />
</div>
);
};
const ProfileChart = () => {
const [chartData, setChartData] = useState(null);
useEffect(() => { // ④, ⑥
fetchChartData().then((p) => setChartData(p));
}, []);
if (chartData === null) { // ⑤
return (
<Typography variant="p" component="h7">
Loading chart...
</Typography>
);
}
return <ChartContent data={chartData} />;
};
コードの実行順は以下のようになります。
①プロフィールデータ取得開始->②待機->③プロフィールデータ取得完了->④グラフデータ取得開始->⑤待機->⑥グラフデータ取得完了
グラフデータの取得はプロフィールデータを取得するまで開始されません。これをウォーターフォールといいます。
2. Fetch-Then-Render(Suspense未使用)
Fetch-Then-Renderは、Fecth-on−Renderで起こったウォーターフォールを防止するために、データ取得を1つの副作用で行うアプローチのことをいいます。
FetchThenRender.jsx
const promise = fetchProfileData();
export const FetchThenRender = () => {
const [userData, setUserData] = useState(null);
const [chartData, setChartData] = useState(null);
useEffect(() => { // ①, ②, ③, ④, ⑤
promise.then((data) => {
setUserData(data.userData);
setChartData(data.chartData);
});
}, []);
if (userData === null) {
return (
<Typography variant="p" component="h7">
Loading profile...
</Typography>
);
}
return (
<div>
<DetailsContent
company={userData.data.company}
name={userData.data.name}
image={userData.data.image}
/>
<ProfileChart chartData={chartData} />
</div>
);
};
const ProfileChart = ({ chartData }) => {
if (chartData === null) {
return (
<Typography variant="p" component="h7">
Loading chart...
</Typography>
);
}
return <ChartContent data={chartData} />;
};
コードの実行順は以下のようになります。
①プロフィールデータ取得開始->②グラフデータ取得開始->③待機->④プロフィールデータ取得完了->⑤グラフデータ取得完了
Fetch-on-Renderで発生したウォーターフォールは解決されましたが、ここでも別のウォーターフォールが発生しています。fetchProfileData内部のPromise.all()ですべてのデータが到着するまで待機しているので、グラフデータがロードされるまでプロフィール部分をレンダーすることができません。
noSuspenseApi.js
export const fetchProfileData = () => {
return Promise.all([fetchUserData(), fetchChartData()]).then(
([userData, chartData]) => {
return { userData, chartData };
}
);
};
3. Render-as-You-Fetch(Suspense使用)
Fetch-on-RenderやFetch-then-RenderのようなSuspenseを使用しないアプローチでは、データ取得完了->レンダー開始という順番でしたが、Suspenseを使うと、このステップが入れ替わり、レンダー開始->データ取得完了という順番になります。
RenderAsYouFetch.jsx
const resource = fetchProfileData();
export const RenderAsYouFetch = () => {
return <ProfilePage />;
};
const ProfilePage = () => {
return (
<Suspense
fallback={
<Typography variant="p" component="h7">
Loading profile...
</Typography>
}
>
<ProfileDetails />
<Suspense
fallback={
<Typography variant="p" component="h7">
Loading chart...
</Typography>
}
>
<ProfileChart />
</Suspense>
</Suspense>
);
};
const ProfileDetails = () => {
const userData = resource.userData.read();
return (
<DetailsContent
company={userData.data.company}
name={userData.data.name}
image={userData.data.image}
/>
);
};
const ProfileChart = () => {
const data = resource.chartData.read();
return <ChartContent data={data} />;
};
以上のコードでは、レンダー時点でfetchProfileData()をつかってリクエストがスタートしています。今回の検証ではフェイクのAPI実装を行っていますが、実際はRelayやApolloのようなデータ取得ライブラリのSuspense連携機能を使います(上述していますが、連携機能がテストされているのは今のところRelayのみです)。
RenderAsYouFetchのレンダーを試みます。子要素として、ProfileDetailsとProfileChartが返ります。
ProfileDetailsのレンダーを試みます。内部でresource.userData.read()が呼び出されます。データはまだ取得されていないので、このコンポーネントはSuspendし、ツリーの他のコンポーネントのレンダーを試みます。
ProfileChartのレンダーを試みます。内部でresource.chartData.read()が呼び出されます。こちらのデータもまだ取得されていないので、このコンポーネントはSuspendします。Reactはこのコンポーネントも飛ばして、ツリーの他のコンポーネントのレンダーを試みます。
レンダーを試みる他のコンポーネントは残っていないので、ProfileDetails直上のSuspendのfallbackを表示します。
fallback={
<Typography variant="p" component="h7">
Loading chart...
</Typography>
}
このように、データが到着するごとに再レンダーを試み、そのたびに深いところまで表示できるようになります。Fetch-Then-Renderでも存在していたウォーターフォールの問題がありません。
Fetch-Then-Renderのようにプロフィールとグラフを同時に出現させたい場合は、ProfileChartをラップしているSuspenseを取り除けば可能です。
<Suspense
fallback={
<Typography variant="p" component="h7">
Loading profile...
</Typography>
}
>
<ProfileDetails />
<ProfileChart />
</Suspense>
4. Race Condition
Suspenseによるインターフェースプレビューの実装の前に、useEffectやcomponentDidMountでデータ取得を行った場合に引き起こされるRace condition(競合状態)についても検証しました。
ContentionalStateコンポーネントをつくり、ProfilePageと複数のプロフィールを切り替えるボタンを配置しました。
const getNextId = (id) => {
return id === 3 ? 0 : id + 1;
};
export const ContentionState = () => {
const [id, setId] = useState(0);
return (
<div>
<Button
variant="outlined"
color="primary"
onClick={() => setId(getNextId(id))}
>
次へ
</Button>
<ProfilePage id={id} />
</div>
);
};
ProfilePageとProfileChartの両方にuseEffectがあり、idが変わるたびにそれぞれのデータを取得します。"次へ"ボタンを素早く押すと、プロフィールを別の ID に切り替えた後に以前のプロフィールのリクエストが返ってくることがあります。
React コンポーネントにはライフサイクルがありますが、非同期なリクエストにも同様に、発行したときに始まり、レスポンスを得た時に終わるというライフサイクルをもっています。このような複数のプロセスが互いに影響しあうことで、競合状態が起こります。
ContentionalState.jsx
const getNextId = (id) => {
return id === 3 ? 0 : id + 1;
};
export const ContentionState = () => {
const [id, setId] = useState(0);
return (
<div>
<Button
variant="outlined"
color="primary"
onClick={() => setId(getNextId(id))}
>
次へ
</Button>
<ProfilePage id={id} />
</div>
);
};
const ProfilePage = ({ id }) => {
const [userData, setUserData] = useState(null);
useEffect(() => {
fetchUserData(id).then((u) => setUserData(u));
}, [id]);
if (userData === null) {
return (
<Typography variant="p" component="h7" style={{ marginLeft: 10 }}>
Loading profile...
</Typography>
);
}
return (
<div>
<DetailsContent
company={userData.data.company}
name={userData.data.name}
image={userData.data.image}
/>
<ProfileChart id={id} />
</div>
);
};
const ProfileChart = ({ id }) => {
const [chartData, setChartData] = useState(null);
useEffect(() => {
fetchChartData(id).then((p) => setChartData(p));
}, [id]);
if (chartData === null) {
return (
<Typography variant="p" component="h7">
Loading chart...
</Typography>
);
}
return <ChartContent data={chartData} />;
};
5. Concurrent Mode
ここからインターフェースプレビューを実装するのですが、まずはRace Conditionを解決する必要があります。
Render-as-You-Fetchの実装ではリソースをトップレベルの変数として定義していましたが、今回は複数リソースを扱うため、ConcurrentModeコンポーネント内で定義します。
const initialResource = fetchProfileData(0);
export const ConcurrentMode = () => {
const [resource, setResource] = useState(initialResource);
"次へ"ボタンのクリックハンドラで、プロフィールを切り替えるためのstateを設定し、さらにこのstateの更新をuseTransitionのstartTransitionでラップします。これにより、stateの更新で望ましくないローディング中の状態の表示が起きてしまった場合に、Reactがstate更新を遅延させることができます。
<Button
variant="outlined"
color="primary"
disabled={isPending}
onClick={() => {
startTransition(() => {
const nextUserId = getNextId(resource.userId);
setResource(fetchProfileData(nextUserId));
});
}}
>
次へ
</Button>
さらにuseTransitionのisPendingを使用することで、トランジションの完了を待機しているかどうかを確認することができ、これでようやく前の画面と"Loading..."を同時に表示するというインターフェースプレビュー の状態を実現することができました。
const [startTransition, isPending] = useTransition({
timeoutMs: 3000,
});
ConcurrentMode.jsx
const getNextId = (id) => {
return id === 3 ? 0 : id + 1;
};
const initialResource = fetchProfileData(0);
export const ConcurrentMode = () => {
const [resource, setResource] = useState(initialResource);
const [startTransition, isPending] = useTransition({
timeoutMs: 3000,
});
return (
<div>
<Button
variant="outlined"
color="primary"
disabled={isPending}
onClick={() => {
startTransition(() => {
const nextUserId = getNextId(resource.userId);
setResource(fetchProfileData(nextUserId));
});
}}
>
次へ
</Button>
{isPending ? ' Loading...' : null}
<ProfilePage resource={resource} />
</div>
);
};
const ProfilePage = ({ resource }) => {
return (
<Suspense
fallback={
<Typography variant="p" component="h7" style={{ marginLeft: 10 }}>
Loading profile...
</Typography>
}
>
<ProfileDetails resource={resource} />
<Suspense
fallback={
<Typography variant="p" component="h7">
Loading chart...
</Typography>
}
>
<ProfileChart resource={resource} />
</Suspense>
</Suspense>
);
};
const ProfileDetails = ({ resource }) => {
const userData = resource.userData.read();
return (
<DetailsContent
company={userData.data.company}
name={userData.data.name}
image={userData.data.image}
/>
);
};
const ProfileChart = ({ resource }) => {
const data = resource.chartData.read();
return <ChartContent data={data} />;
};
おわりに
公式Docsの説明が詳しかったこともあり、自分で実装しながらReactのレンダリングへの理解を深めることができました。Concurrent Modeは実験的機能であり、React Nativeにもまだ対応していません。アプリへの導入はまだ先になるかもしれませんが、正式リリースが今から楽しみです。