よくある UI のひとつにページング UI があります。ユーザーが画面を開いたら Web API にページ番号(またはカーソル)を渡してデータの配列を取得して画面に表示します。ユーザーが「次のページ」「前のページ」または特定のページ番号へ移動するボタンをクリックすると、新しいページ番号で再度 Web API にリクエストを送信し、新しいデータの配列を画面表示します。
React18 の useTransition を使用することでこのページング UI の体験を良くすることができるので紹介します。
前置き
サンプルアプリはスタイリングのために Chakra UI を使用していますが、本題ではないのでコンポーネントの説明はしません。雰囲気で読めると思います。
サンプルアプリ仕様
GitHub API を使用して facebook/react リポジトリの issues 一覧を表示します。
https://api.github.com/repos/facebook/react/issues
前後ボタンと issue のリストだけが画面に表示されるアプリです。
ページングが主題なので、その他のパラメーターはハードコーディングします。
準備
今回使う GitHub issues API のレスポンスは次のようなオブジェクトの配列になっています。
type IssueType = {
id: number;
title: string;
html_url: string;
user: {
id: number;
login: string;
html_url: string;
};
};
使用したいプロパティだけ抜粋して型定義しています。 user
はその issue を作成したユーザー情報で、 html_url
は当該 issue または user の web ページの URL を指します。
このオブジェクトをひとつ受け取って画面に表示するコンポーネント Issue
を作成します。
const Issue: FC<{ issue: IssueType }> = ({ issue }) => {
return (
<Stack
as="article"
key={issue.id}
borderRadius="md"
border="1px solid"
borderColor="whiteAlpha.100"
paddingY="4"
paddingX="4"
boxShadow="md"
>
<Heading fontSize="lg">
<Link isExternal href={issue.html_url}>
{issue.title}
</Link>
</Heading>
<Text color="blackAlpha.600" fontSize="sm">
opened by{" "}
<Link isExternal href={issue.user.html_url}>
{issue.user.login}
</Link>
</Text>
</Stack>
);
};
IssueType
を props で受け取って issue.title
と issue.user.login
を外部リンクで表示するだけのコンポーネントです。ステートなしのシンプルなコンポーネントですね。
最低限の実装
ページ番号ステートを管理するカスタムフックを用意しましょう。
function usePageNumber() {
const [page, setPage] = useState(1);
const incrementPage = useCallback(() => {
setPage((p) => p + 1);
}, []);
const decrementPage = useCallback(() => {
setPage((p) => Math.max(p - 1, 1));
}, []);
return {
page,
incrementPage,
decrementPage,
};
}
いたって単純ですが、ページを減らすときに 1 より小さくならないようにだけしています。
続いて、ページ番号を受け取ったら GitHub API にリクエストを送信して取得した issues を表示するコンポーネント Issues
を作成します(型チェックやエラーチェックはサボります)。
const Issues: FC<{ page: number }> = ({ page }) => {
const { data: issues } = useSWR(["facebook/react/issues", page], () => {
const url = `https://api.github.com/repos/facebook/react/issues?per_page=10&state=all&page=${page}`;
return fetch(url).then<IssueType[]>((r) => r.json());
});
return (
<Stack spacing="4">
{issues?.map((issue) => (
<Issue key={issue.id} issue={issue} />
))}
</Stack>
);
};
useSWR
を使用していますが、 react-query でも自作のフックでもいいです。
GitHub issues API の URL に page
パラメーターを渡し、 fetch
でリクエストを送信します。取得できた issues
配列の .map()
で Issue
コンポーネントを個数分だけ描画します。
最後にルートコンポーネントです。
export default function App() {
const { page, incrementPage, decrementPage } = usePageNumber();
return (
<Box paddingY="8" paddingX="12">
<HStack justifyContent="flex-end">
<Button colorScheme="green" onClick={decrementPage}>
前へ
</Button>
<Button colorScheme="green" onClick={incrementPage}>
次へ
</Button>
</HStack>
<Box marginTop="8">
<Issues page={page} />
</Box>
</Box>
);
}
先程作ったページ番号を管理するカスタムフックを実行します。そのフックから取得する page
を Issues
に渡します。 page
を操作する incrementPage
と decrementPage
はそれぞれ「前へ」「次へ」ボタンに渡します。
これで最低限のアプリは完成です。動かしている様子を見てみましょう。
「次へ」ボタンを押すと一旦 issues
ステートが undefined
になるため、画面からすべて消えてしまいます。「前へ」ボタンはキャッシュが効いて問題ありませんが、これではチカチカして煩わしいですね。また、もしネットワーク速度が遅い端末で閲覧した場合、真っ白な状態が長く続くことになり、体験を損ねます。
これを React18 から入った機能によって改善してみましょう。
Suspense を使う
useSWR
はオプションひとつでコンポーネントのレンダリングをサスペンドさせる機能があります。React においてレンダリングのサスペンドとは Promise
オブジェクトを throw
することです。これについては詳しい記事がたくさんあります。下の 2 つ目の記事は特に面白いのでおすすめです。
useSWR
を使う場合は 3 つ目の引数に { suspense: true }
を渡します。 react-query もまったく同じオブジェクトです。自作のクエリフックでやる場合はなんとかサスペンドさせてください。とにかく Promise
を throw
すればいい感じになります(?)
const Issues: FC<{ page: number }> = ({ page }) => {
const { data: issues } = useSWR(
["facebook/react/issues", page],
() => {
const url = `https://api.github.com/repos/facebook/react/issues?per_page=10&state=all&page=${page}`;
return fetch(url).then<IssueType[]>((r) => r.json());
},
+ { suspense: true }
);
return (
<Stack spacing="4">
{issues?.map((issue) => (
<Issue key={issue.id} issue={issue} />
))}
</Stack>
);
};
このままだと Promise
が throw
されっぱなしになります。 error の throw
と同様にどこかでキャッチしないとアプリのクラッシュとみなされてしまいます。 throw
された Promise
をキャッチする役割を持つのが Suspense
コンポーネントです。 Suspense
コンポーネントは Promise
をキャッチすると、その Promise
が解決されるまで代わりに fallback
props に渡された要素を描画してくれます。
Suspense
は Promise
を throw
するであろうコンポーネントの祖先として配置します。今回は App
コンポーネントに Issues
コンポーネントを覆うようにして配置しましょう。 fallback
props も Chakra UI を使ってよしなに作ります。
export default function App() {
const { page, incrementPage, decrementPage } = usePageNumber();
return (
<Box paddingY="8" paddingX="12">
{/* 前後ボタンは変更なしなので省略 */}
<Box marginTop="8">
+ <Suspense
+ fallback={
+ <Center>
+ <Spinner size="xl" />
+ </Center>
+ }
+ >
<Issues page={page} />
+ </Suspense>
</Box>
</Box>
);
}
ここまででアプリの挙動を確認してみましょう。
ボタンをクリックすると新しいデータを取得している間コンポーネントがサスペンドします。サスペンド中は Suspense
の fallback
に渡したスピナーが代わりに描画されているのがわかります。
これなら真っ白な状態が続くことなく、ユーザーにローディング中であることを示すことで現在のアプリの状態を伝えることができます。
しかし、画面に表示されているコンテンツがバッサリと削除されてチカチカするのには変わりありません。解決策としては、次のページの読み込みが完了するまでは今のページをユーザーに見ていてもらえばいいのではないでしょうか。旧コンテンツは引き続き閲覧できるはずなのにわざわざ画面から消してスピナーだけ見せても手持ち無沙汰になるだけでもったいないですよね。
これを React 標準で解決できるのが useTransition
です。
useTransition を使う
ページ番号を管理するカスタムフックを次のように修正します。
function usePageNumber() {
const [page, setPage] = useState(1);
+ const [isPending, startTransition] = useTransition();
const incrementPage = useCallback(() => {
- setPage((p) => p + 1);
+ startTransition(() => setPage((p) => p + 1));
}, []);
const decrementPage = useCallback(() => {
- setPage((p) => Math.max(p - 1, 1));
+ startTransition(() => setPage((p) => Math.max(p - 1, 1)));
}, []);
return {
page,
+ isPending,
incrementPage,
decrementPage,
};
}
useTransition
は 2 つの要素を持つタプルを返します。1 つ目はペンディング状態を表す boolean
値で、2 つ目は startTransition
です。 startTransition
のコールバック関数の中で行われるステート更新によってサスペンドが発生すると、 Suspense
の fallback
は描画されず現在の DOM が維持されます。そして Promise
が解決された段階で改めて新しいステートで DOM が更新されます。
これはまさに求めていた挙動です。取得が完了するまでは前のコンテンツを見せておき、取得が完了したら一瞬で新コンテンツを見せる。そうすることでユーザーが手持ち無沙汰になることもありません。
もちろん前のコンテンツだけを見せるのでは、ちゃんと次のコンテンツの取得を開始しているかどうかがわかりません。それは isPending
によって取得中かどうかのビューを見せることで解決できます。
ということで、 新しく useTransition
を組み込んだ usePageNumber
で App
を修正しましょう。
export default function App() {
- const { page, incrementPage, decrementPage } = usePageNumber();
+ const { page, isPending, incrementPage, decrementPage } = usePageNumber();
return (
<Box paddingY="8" paddingX="12">
<HStack justifyContent="flex-end">
+ {isPending && <Spinner />}
<Button
colorScheme="green"
onClick={decrementPage}
+ isDisabled={isPending}
>
前へ
</Button>
<Button
colorScheme="green"
onClick={incrementPage}
+ isDisabled={isPending}
>
次へ
</Button>
</HStack>
{/* Suspense と Issues は変更なしなので省略 */}
</Box>
);
}
isPending
が true
の場合は Issues
が旧データを表示しつつも新データを取得中なので、ボタンの横にスピナーをつけることでローディング状態を伝えます。また、ボタンも連打できないように isDisabled
にしておくといいでしょう。
これでアプリの挙動を見てみます。
どうですか!
画面からコンテンツが消えないのでボタンのクリックでチカチカすることがなくなったものの、スピナーが表示されたりボタンが非アクティブになるといったユーザーに対するフィードバックがあります!これでユーザーの体験を損ねることがないページング UI の実装ができました!
今回作成したアプリのデモは下の Codesandbox にあるので、実際に動かしてみてください。
まとめ
React の useTransition
でページング UI のユーザー体験を向上させる手順を説明してきました。
もちろんページング以外にも非同期処理が絡む Web アプリケーションで useTransition
を始めとした React18 の新 API が活きるケースは多いでしょう。
React が標準機能としてユーザー体験を意識した API を提供してくれるので活かさない選択はありません。
それではよい React ライフを!