80
53

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Last updated at Posted at 2022-07-17

よくある 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.titleissue.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>
  );
}

先程作ったページ番号を管理するカスタムフックを実行します。そのフックから取得する pageIssues に渡します。 page を操作する incrementPagedecrementPage はそれぞれ「前へ」「次へ」ボタンに渡します。

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

facebook/react の issues をページング表示し、前後ボタンを何度かクリックするアニメーション

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

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

Suspense を使う

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

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

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

このままだと Promisethrow されっぱなしになります。 error の throw と同様にどこかでキャッチしないとアプリのクラッシュとみなされてしまいます。 throw された Promise をキャッチする役割を持つのが Suspense コンポーネントです。 Suspense コンポーネントは Promise をキャッチすると、その Promise が解決されるまで代わりに fallback props に渡された要素を描画してくれます。

SuspensePromisethrow するであろうコンポーネントの祖先として配置します。今回は 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>
  );
}

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

facebook/react の issues をページング表示し、前後ボタンをクリックするとスピナーが表示されるアニメーション

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

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

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

これを 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 のコールバック関数の中で行われるステート更新によってサスペンドが発生すると、 Suspensefallback は描画されず現在の DOM が維持されます。そして Promise が解決された段階で改めて新しいステートで DOM が更新されます。

これはまさに求めていた挙動です。取得が完了するまでは前のコンテンツを見せておき、取得が完了したら一瞬で新コンテンツを見せる。そうすることでユーザーが手持ち無沙汰になることもありません。

もちろん前のコンテンツだけを見せるのでは、ちゃんと次のコンテンツの取得を開始しているかどうかがわかりません。それは isPending によって取得中かどうかのビューを見せることで解決できます。

ということで、 新しく useTransition を組み込んだ usePageNumberApp を修正しましょう。

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

isPendingtrue の場合は Issues が旧データを表示しつつも新データを取得中なので、ボタンの横にスピナーをつけることでローディング状態を伝えます。また、ボタンも連打できないように isDisabled にしておくといいでしょう。

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

facebook/react の issues をページング表示し、前後ボタンをクリックするとボタンの横にスピナーが表示されるがコンテンツは消える瞬間がないのを示すアニメーション

どうですか!

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

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

まとめ

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

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

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

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

80
53
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
80
53

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?