valtioで状態管理しながら、React Suspenseについて理解を深める

2022.05.13

こんにちは。データアナリティクス事業本部 サービスソリューション部の北川です。

今回は、状態管理にvaltioを使いながら、React Suspenseについて、理解を深めていきたいと思います。

React Suspenseとは

React Suspenseとは、簡単にいうとコンポーネントがロードされるまでの「待機中」状態を提供できる機能になります。

Suspense for Data Fetchingは、データを含む他の何かを宣言的に「待機」するためにも使用できる新機能です。このページでは、データフェッチのユースケースに焦点を当てていますが、画像、スクリプト、またはその他の非同期作業を待機することもできます。

環境作成

Suspenseを使うための環境を作成します。

npx create-react-app sample-app --template typescript

cd sample-app

valtioをインストール。

yarn add valtio

まずは、Suspenseを使用せずにデータをフェッチしてみます。今回は、JSONPlaceholderから、値を取ってきて表示してみます。

App.tsxを変更します。valtioでは、状態をproxyでラッピングして管理し、useSnapshotを使用してコンポーネント内に呼び出します。

App.tsx

import { proxy, useSnapshot } from "valtio";

const url = "https://jsonplaceholder.typicode.com/todos/1";

const state = proxy({ post: fetch(url).then((res) => res.json()) });

const Post = () => {
  const snap = useSnapshot(state);
  return <div>{snap.post.title}</div>;
};

const App = () => {
  return (
    <div>
      <h1 style={{ background: "pink" }}>Suspense</h1>
      <Post />
    </div>
  );
};

export default App;

Postコンポーネントが表示されるのを待って、Appコンポーネント全体が、同時に表示されています。

Suspenseの使用

では、Suspenseを使用してみます。Suspenseでコンポーネントを囲むことで、データを取得するまでの間、代わりにフォールバックコンテンツを表示してくれます。Suspenseについては、これだけで実装できます。

App.tsx

const App = () => {
  return (
    <div>
      <h1 style={{ background: "pink" }}>Suspense</h1>
      <Suspense fallback={<span>waiting...</span>}>
        <Post />
      </Suspense>
    </div>
  );
};

JSONPholderからデータが取得されるまで、fallbaskの値を返しています。

Suspenseを複数使用

Suspenseは一つのコンポーネント内に複数使用でき、レンダリングできるよう対処できたコンポーネントから、順に表示することができます。

srcフォルダにContainer.tsxを作成します。

Container.tsx

import { FC } from "react";
import { proxy } from "valtio";

const sleep = (ms: number) => {
  return new Promise((resolve) => setTimeout(resolve, ms));
};

type State = {
  type: string;
  value: string | undefined;
};

type Key = "todo" | "post" | "user";

export const state = proxy<{
  [K in Key]: State;
}>({
  todo: { type: "todo", value: undefined },
  post: { type: "post", value: undefined },
  user: { type: "user", value: undefined },
});

const fetchData = async (num: number, type: string) => {
  await sleep(num);
  switch (type) {
    case "todo":
      state.todo.value = type;
      break;
    case "post":
      state.post.value = type;
      break;
    case "user":
      state.user.value = type;
      break;
    default:
      break;
  }
};

type Props = {
  num: number;
  state: State;
};

export const Container: FC<Props> = (props) => {
  if (props.state.value === undefined) {
    throw fetchData(props.num, props.state.type);
  }

  return (
    <div style={{ background: "skyblue" }}>
      <p>{props.state.value}</p>
    </div>
  );
};

少し冗長ですが、以下のように記述します。

また、App.tsxを変更します。

App.tsx

import { Suspense } from "react";
import { useSnapshot } from "valtio";
import { Container, state } from "./Container";

const App = () => {
  const snap = useSnapshot(state);

  return (
    <div>
      <h1 style={{ background: "pink" }}>Suspense</h1>
      <Suspense fallback={<p>waiting...</p>}>
        <Container num={2000} state={snap.todo} />
      </Suspense>
      <Suspense fallback={<p>waiting...</p>}>
        <Container num={3000} state={snap.post} />
      </Suspense>
      <Suspense fallback={<p>waiting...</p>}>
        <Container num={1000} state={snap.user} />
      </Suspense>
    </div>
  );
};

export default App;

それぞれ待機時間をずらして、propsを渡してみました。

順に表示されていますね。

ここで、Suspense内のContainerコンポーネントで、コンソールを表示してみます。

Container.tsx

export const Container: FC<Props> = (props) => {
  if (props.state.value === undefined) {
    throw fetchData(props.num, props.state.type);
  }

  console.log(props.state.type);

  return (
    <div style={{ background: "skyblue" }}>
      <p>{props.state.value}</p>
    </div>
  );
};

こちらで分かる通り、コンポーネントがサスペンドして表示できない場合は、suspenseによってコンポーネントは無視されますが、一度表示されてしまえば次回のレンダリングには関与していることが分かります。

Suspenseをネスト

Suspenseの中にSuspenseを入れてみます。

App.tsx

    <div>
      <h1 style={{ background: "pink" }}>Suspense</h1>
      <Suspense fallback={<p>waiting...</p>}>
        <Container num={2000} state={snap.todo} />
      </Suspense>
      <Suspense fallback={<p>waiting...</p>}>
        <Suspense fallback={<p>waiting...</p>}>
          <Container num={3000} state={snap.post} />
        </Suspense>
        <Container num={1000} state={snap.user} />
      </Suspense>
    </div>

こちらも問題なく動作しています。

また、下階層のSuspense内コンポーネントの待機時間が、親階層の待機時間より短い場合、表示されていなくても、レンダリングが開始されていることが分かります。

サスペンド状態を制御

次にデータ取得を制御し、サスペンド状態を維持してみました。これに関しては、どちらかというと、valtioの挙動確認になりますね。

Container.tsxを以下のように変更します。

Container.tsx

import { FC } from "react";
import { proxy } from "valtio";

const sleep = (ms: number) => {
  return new Promise((resolve) => setTimeout(resolve, ms));
};

type State = {
  type: string;
  value: string | undefined;
  isOpen: boolean;
};

type Key = "todo" | "post" | "user";

export const state = proxy<{
  [K in Key]: State;
}>({
  todo: { type: "todo", value: undefined, isOpen: false },
  post: { type: "post", value: undefined, isOpen: false },
  user: { type: "user", value: undefined, isOpen: false },
});

const fetchData = async (num: number, type: string) => {
  await sleep(num);
  switch (type) {
    case "todo":
      state.todo.value = type;
      break;
    case "post":
      state.post.value = type;
      break;
    case "user":
      state.user.value = type;
      break;
    default:
      break;
  }
};

export const toggleIsOpen = async (type: string) => {
  switch (type) {
    case "todo":
      state.todo.isOpen = !state.todo.isOpen;
      break;
    case "post":
      state.post.isOpen = !state.post.isOpen;
      break;
    case "user":
      state.user.isOpen = !state.user.isOpen;
      break;
    default:
      break;
  }
};

type Props = {
  num: number;
  state: State;
};

export const Container: FC<Props> = (props) => {
  if (!props.state.isOpen) {
    throw sleep(1000000);
  }

  if (props.state.value === undefined) {
    throw fetchData(props.num, props.state.type);
  }

  return (
    <div style={{ background: "skyblue" }}>
      <p>{props.state.value}</p>
    </div>
  );
};

状態にisOpenを持たせてfalseの場合、無理やりですがコンポーネントを、永続的にサスペンド状態にしています。

toggleIsOpenという関数でisOpenの値を変更します。次に、関数を呼び出すための、ButtonListというコンポーネントを作成します。

ButtonList

import { toggleIsOpen } from "./Container";

export const ButtonList = () => {
  return (
    <div>
      {["todo", "post", "user"].map((value) => {
        return (
          <button
            style={{ margin: "10px" }}
            key={value}
            onClick={() => toggleIsOpen(value)}
          >
            {value}
          </button>
        );
      })}
    </div>
  );
};

App.tsxでButtonListコンポーネントを呼び出します。

App.tsx

const App = () => {
  const snap = useSnapshot(state);
  return (
    <div>
      <h1 style={{ background: "pink" }}>Suspense</h1>
      <Suspense fallback={<p>waiting...</p>}>
        <Container num={2000} state={snap.todo} />
      </Suspense>
      <Suspense fallback={<p>waiting...</p>}>
        <Container num={3000} state={snap.post} />
      </Suspense>
      <Suspense fallback={<p>waiting...</p>}>
        <Container num={1000} state={snap.user} />
      </Suspense>
      <ButtonList />
    </div>
  );
};

ボタンを押したコンポーネントだけ、サスペンドを終了することができました。また、一度ボタンを押したコンポーネントは待機時間なく表示されており、状態管理もうまく機能しています。

まとめ

色々試すにあたって、こちらの記事(デジタル書籍/無料)で学ばさせていただきました。

ハンズオン形式で解説されており、手を動かしながら学べます。執筆者は、「プロを目指す人のためのTypeScript入門」の著者です。私も現在、読書中です。

今回のアウトプットを通じて、Suspenseについての理解を深めることができました。レンダリングに関する非同期処理は奥が深く、まだまだ学ぶことが多いですが、これからも情報発信を続けていければと思います。

ではまた。