【React】useTransition でページング UI のユーザー体験を向上させる

https://qiita.com/official-events/6677e0827fec55a3b95f

よくある 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 のレスポンスは次のようなオブジェクトの配列になっています。

```tsx
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` を作成します。

```tsx
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` を外部リンクで表示するだけのコンポーネントです。ステートなしのシンプルなコンポーネントですね。

## 最低限の実装

ページ番号ステートを管理するカスタムフックを用意しましょう。

```tsx
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` を作成します(型チェックやエラーチェックはサボります)。

```tsx
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` コンポーネントを個数分だけ描画します。

最後にルートコンポーネントです。

```tsx
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` はそれぞれ「前へ」「次へ」ボタンに渡します。

これで最低限のアプリは完成です。動かしている様子を見てみましょう。

![facebook/react の issues をページング表示し、前後ボタンを何度かクリックするアニメーション](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/299625/0bd57494-7b62-ad33-cde9-4e93d8dace95.gif)

「次へ」ボタンを押すと一旦 `issues` ステートが `undefined` になるため、画面からすべて消えてしまいます。「前へ」ボタンはキャッシュが効いて問題ありませんが、これではチカチカして煩わしいですね。また、もしネットワーク速度が遅い端末で閲覧した場合、真っ白な状態が長く続くことになり、体験を損ねます。

これを React18 から入った機能によって改善してみましょう。

## Suspense を使う

`useSWR` はオプションひとつでコンポーネントのレンダリングをサスペンドさせる機能があります。React においてレンダリングのサスペンドとは `Promise` オブジェクトを `throw` することです。これについては詳しい記事がたくさんあります。下の 2 つ目の記事は特に面白いのでおすすめです。

https://qiita.com/uhyo/items/4a6315bfccf387407631

https://qiita.com/uhyo/items/255760315ca61544fe33

`useSWR` を使う場合は 3 つ目の引数に `{ suspense: true }` を渡します。 react-query もまったく同じオブジェクトです。自作のクエリフックでやる場合はなんとかサスペンドさせてください。とにかく `Promise` を `throw` すればいい感じになります(？)

```diff_tsx
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 を使ってよしなに作ります。

```diff_tsx
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>
  );
}
```

ここまででアプリの挙動を確認してみましょう。

![facebook/react の issues をページング表示し、前後ボタンをクリックするとスピナーが表示されるアニメーション](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/299625/81fbc425-4a9d-27c9-59e0-ab4e19d84c4d.gif)

ボタンをクリックすると新しいデータを取得している間コンポーネントがサスペンドします。サスペンド中は `Suspense` の `fallback` に渡したスピナーが代わりに描画されているのがわかります。

これなら真っ白な状態が続くことなく、ユーザーにローディング中であることを示すことで現在のアプリの状態を伝えることができます。

しかし、画面に表示されているコンテンツがバッサリと削除されてチカチカするのには変わりありません。解決策としては、次のページの読み込みが完了するまでは今のページをユーザーに見ていてもらえばいいのではないでしょうか。旧コンテンツは引き続き閲覧できるはずなのにわざわざ画面から消してスピナーだけ見せても手持ち無沙汰になるだけでもったいないですよね。

これを React 標準で解決できるのが `useTransition` です。

## useTransition を使う

ページ番号を管理するカスタムフックを次のように修正します。

```diff_tsx
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` を修正しましょう。

```diff_tsx
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` にしておくといいでしょう。

これでアプリの挙動を見てみます。

![facebook/react の issues をページング表示し、前後ボタンをクリックするとボタンの横にスピナーが表示されるがコンテンツは消える瞬間がないのを示すアニメーション](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/299625/efa37126-9c39-6e1d-4e9d-4056926d7428.gif)

どうですか！

画面からコンテンツが消えないのでボタンのクリックでチカチカすることがなくなったものの、スピナーが表示されたりボタンが非アクティブになるといったユーザーに対するフィードバックがあります！これでユーザーの体験を損ねることがないページング UI の実装ができました！

今回作成したアプリのデモは下の Codesandbox にあるので、実際に動かしてみてください。

https://codesandbox.io/s/qiita-react-18-fetch-pagination-api-kp9mdf?file=/src/App.tsx

## まとめ

React の `useTransition` でページング UI のユーザー体験を向上させる手順を説明してきました。

もちろんページング以外にも非同期処理が絡む Web アプリケーションで `useTransition` を始めとした React18 の新 API が活きるケースは多いでしょう。

React が標準機能としてユーザー体験を意識した API を提供してくれるので活かさない選択はありません。

それではよい React ライフを！