2022/03/29にReact18はリリースされました。https://github.com/facebook/react/releases/tag/v18.0.0
この記事はリリース前に書いたものですので若干の変更がある恐れがあります。
はじめに
React16.6から実験的機能としてSuspenseコンポーネントが実装されていたが、React18では正式にSuspenseが実装される。この記事ではSuspenseについて解説する。また、例として出すプログラムは全てFunctionコンポーネントを使用した記述とする。
Suspenseとは
Suspenseはあるコンポーネントが表示可能になるまでの状態(待機状態)を指定することが可能なコンポーネントである。
よく使われそうな事象としては、Suspenseコンポーネントのpropsであるfallback
に<Loding />
(待機画面のコンポーネント)を指定し、その配下に非同期で取得するデータを扱うコンポーネントを配置するようなものである。このようにすることで、データ取得までは<Loading />
を呼び出し、取得後はデータを扱うコンポーネントを呼び出すような処理ができる。
非同期で取得するデータを扱うコンポーネントとして<MyPage />
を考える。<MyPage />
を呼び出し、非同期のデータを取得するまで代わりに<Loading />
を呼び出すようなプログラムは以下のように書くことができる。
<Suspense fallback={<Loading />}>
<MyPage />
</Suspense>
従来法との比較
このような表示方法はSuspenseを用いるまでもなく、以前から使われてきた。そこで従来の表示方法を紹介する。また、Suspenseを用いた実装と比較をおこないメリットを紹介する。
例としてユーザー名と投稿が見れる掲示板アプリのようなページを考える。従来の実装法では以下のようなプログラムとなる。
function MyPage(): JSX.Element {
const [user, setUser] = React.useState<User | null>(null);
useEffect(() => {
fetchUser().then(u => setUser(u));
}, []);
if (user === null) {
return <Loading kind="user" />
}
return (
<>
<h1>{user.name}</h1>
<Post />
</>
);
}
function Post(): JSX.Element {
const [posts, setPosts] = useState<Post[] | null>(null);
useEffect(() => {
fetchPosts().then({data} => setPosts(data.data));
}, []);
if (family === null) {
return <Loading kind="post" />
}
return (
<ul>
{posts.map(post => (
<li key={post.id}>{post.text}</li>
))}
</ul>
);
}
データ取得はfetchXXX()
のように適当な関数で行えるとした。以上のプログラムは以下のようなフローで動作する。
- ユーザーデータの取得開始
-
<Loading kind="user" />
の呼び出し - ユーザーデータの取得開始
-
<Post />
の呼び出し - 投稿一覧データの取得開始
-
<Loading kind="user" />
の呼び出し - 投稿一覧データの取得完了
- 完全な画面の表示
この場合ユーザーデータの取得が完了してから投稿一覧データを取得することになるので時間の無駄が生じる。これはPromiss.all()
のような機能で一度に取得することで解消可能である。プログラムを以下のよう書き換える。
function MyPage(): JSX.Element {
const [user, setUser] = React.useState<User | null>(null);
const [posts, setPosts] = useState<Post[] | null>(null);
useEffect(() => {
fetchAll().then(data => {
setUser(data.user));
setPosts(data.posts)
}
}, []);
if (user === null) {
return <Loading kind="user" />
}
return (
<>
<h1>{user.name}</h1>
<Post posts={posts} />
</>
);
}
function Post({ posts }: {posts: Post[] | null}): JSX.Element {
if (family === null) {
return <Loading kind="post" />
}
return (
<ul>
{posts.map(post => (
<li key={post.id}>{post.text}</li>
))}
</ul>
);
}
このプログラムは以下のフローで動作する。
- ユーザーデータの取得開始
- 投稿一覧データの取得開始
-
<Loading kind="user" />
の呼び出し - ユーザーデータの取得完了
- 投稿一覧データの取得完了
- 完全な画面の表示
このようにデータの取得を待ってから別データを取得するという無駄は削減できたが、全てのデータを取得するまで全く表示されないという欠点がある。
Suspenceではこの両方の問題を解決することができる。<Suspence />
を用いたプログラムは以下のようになる。
function BBS(): JSX.Element {
return (
<Suspence fallback={<Loading kind="user" />}>
<MyPage />
<Suspence fallback={<Loading kind="post" />}>
<Post />
</Suspence>
</Suspence>
)
}
function MyPage(): JSX.Element {
const [user, setUser] = React.useState<User | null>(null);
useEffect(() => {
fetchUser().then(u => setUser(u));
}, []);
return (
<h1>{user.name}</h1>
);
}
function Post(): JSX.Element {
const [posts, setPosts] = useState<Post[] | null>(null);
useEffect(() => {
fetchPosts().then({data} => setPosts(data.data));
}, []);
return (
<ul>
{posts.map(post => (
<li key={post.id}>{post.text}</li>
))}
</ul>
);
}
投稿一覧データよりユーザーデータを取得する方が早いとき場合と投稿一覧データを取得する方が早い場合で分けて考える。
ユーザーデータを取得する方が早い場合は以下のようなフローで動作する。
-
<MyPage />
と<Post />
の呼び出し -
<MyPage />
と<Post />
の両者データ取得中により待機状態 - 両者待機状態により上位にある
<Loading kind="user" />
の呼び出し - ユーザーデータの取得完了
-
<Post />
のみが待機状態により<Loading kind="post" />
の呼び出し - 投稿一覧データの取得完了
- 完全な画面の表示
また、投稿一覧データを取得する方が早いときは以下のようなフローで動作する。
-
<MyPage />
と<Post />
の呼び出し -
<MyPage />
と<Post />
の両者とデータ取得により待機状態 - 両者待機状態なので上位にある
<Loading kind="user" />
の呼び出し - 投稿一覧データの取得完了
-
<Post />
の待機状態が解除、<MyPage />
は待機状態なので変わらず - ユーザーデータの取得完了
- 完全な画面の表示
後者の場合はPormiss.all()
を使用した時と同様のフローだが、前者の場合は投稿一覧データを取得する前にユーザーデータが表示できる利点があることがわかる。また、データの取得を同時に行なっているので、表示速度も先に紹介した手法と比べて同等以上の速さである。
つまり、Suspenceを使うことでデータの取得を行いながら待機状態が解除されたコンポーネントから表示ができ、かつデータの取得後にさらに別データを取得するような怠惰な処理を行わなくて済むというメリットがある。さらに、<Suspence>
の配下にコンポーネントを置くだけで達成できるので実装の容易さ、コードの統一などの面から見ても便利なものである。
SSRの強化
Suspenceが追加されたことによりReactのSSR(Sever Side Render)がより便利になった。
従来のReactにおけるSSRは以下のようなフローで行う。
- サーバー上で全データ取得
- サーバー上でHTMLをレンダリング
- クライアントでjs読み込み
- クライアントでjsのロジックをHTMLに接続
上記のフローは一つのステップが全て終わるまで次のステップに移れない。これは全てのデータを取得するまでHTMLのレンダリングができず、画面の表示が遅くなる、全てのjsが読み込まれるまでjsのロジックをHTMLに接続できず、画面は表示されているのにイベントが動作しない(jsロジックの)などのデメリットがある。React18ではSuspenceを利用することでストリーミングHTMLと選択的ハイドレーションの二つが大きな特徴が追加された。これによって前述のデメリットは解決される。
ストリーミングHTML
ストリーミングHTMLとはデータの取得前にはfallbackに指定したコンポーネントをその箇所のHTMLとして生成し、データの取得完了後に表示したいHTMLに置き換えるようなスクリプトが書かれた追加のHTMLを送る形式のことである。
例として次のようなプログラムを考える。
function PostDetail({ post }: (post: Post}): JSX.Element {
return (
<>
<Post post={post} />
<Suspence fallback={<Loading />} />
<Comments />
</Suspence>
</>
)
}
これはある投稿の詳細画面を想定して作成した。<Comments />
を読み込む際には投稿に対するコメントを取得する必要があるので<Suspence>
で囲い取得完了まで<Loading />
を代わりに読み込ませるようにした。データを取得する前にSSRした場合以下のようなHTMLが生成される。
<!--Post-->
<article>
<p>XX歳になりました!</p>
</article>
<section id="comments-loading">
<!--Loading-->
<img src="loading.gif" alt="Loading" />
</section>
<article>
に囲まれた部分は<Post>
から生成された部分なので問題なく生成される。<section>
に囲まれた部分はfallback
に指定した<Loading>
が内部に生成される。<section>
はHTMLを置き換える部分の目印として生成され、idが割り振られている。実際にデータの取得完了後はサーバーからこのようなHTMLが送信される。
<div hidden id="comments">
<!-- Comments -->
<p>おめでとうございます。</p>
<p>おめでとうございます。</p>
</div>
<script>
// 簡略化された実装
document.getElementById('sections-spinner').replaceChildren(
document.getElementById('comments')
);
</script>
実装は簡略化されているが、HTMLを受け取ることで<Loading />
の部分が<Comments />
に置き換えられる処理が走ることがわかる。このような方式によってデータの取得まで表示できないという問題を解決している。
選択的ハイドレーション
ハイドレーションとはjsのロジックをHTMLに付与することを指し、従来はページに必要な全てのjsが読み込まれてなければ行うことができなかった。選択的ハイドレーションでは<Suspence>
で囲んだ部分以外のHTMLで必要なjsの読み込みをまず行い付与する、その後<Suspence>
に囲まれた部分のHTMLは読み込み後が完了したものから追加でjsのロジックを付与する。これによってあるjsの読み込みが遅いHTMLがあったとして、そのHTMLに対するjsが読み込まれるまで他の部分のイベントが動作しないと言ったUXの悪い現象を防ぐことができる。また、React18ではjsの読み込みがまだ行われていない部分に対してクリックなどのユーザーからの動作があった場合優先して読み込みを行うことができる。
終わりに
Suspenceの紹介から、Suspenceに置き換えることのメリット、ReactにおけるSSRに与える影響などを紹介した。とても直感的で便利に使えるコンポーネントなので、React18にアップデートした際は使用したい。
参考