UGA Boxxx

つぶやきの延長のつもりで、知ったこと思ったこと書いてます

【React】Suspenseを使ったデータ取得

React Concurrent Mode のSuspense機能についてのお話を聞く機会がありそうなので、事前準備として下のドキュメントを読んでまとめる

ja.reactjs.org

React 16.6 で、レンダー可能になる前のロード中状態(スピナーのようなもの)を宣言的に指定することができる <Suspense> コンポーネントが追加された

以下はざっくりとしたコードの例で、ProfileDetailsProfileTimeline内でデータフェッチが行われている場合、データが揃うまでSuspensefallbackに指定された<h1>Loading profile...</h1><h1>Loading posts...</h1>が画面に表示される

    <Suspense fallback={<h1>Loading profile...</h1>}>
      <ProfileDetails />
      <Suspense fallback={<h1>Loading posts...</h1>}>
        <ProfileTimeline />
      </Suspense>
    </Suspense>

demo

Suspenseの誤解されそうな点

  • データを取得するための実装ではない
  • fetch や Relay をサスペンスで「置き換える」ことはできない
  • React コンポーネントをネットワーク関係のロジックと結合させることはしない

Suspenseでできること

  • データ取得ライブラリと React を連携できる
  • ロード中状態を設計することが容易になる
  • 非同期だがデータを同期的に読み出されているかのように振る舞える(よくわからない)

既存アプローチとサスペンスの比較

1: Fetch-on-Render(サスペンス不使用)

画面上にコンポーネントがレンダーされた後までデータ取得が始まらない
これは “ウォーターフォール” の問題を引き起こす

// In a function component:
useEffect(() => {
  fetchSomething();
}, []);

// Or, in a class component:
componentDidMount() {
  fetchSomething();
}

ウォーターフォール問題とは

function ProfilePage() {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetchUser().then(u => setUser(u));
  }, []);

  if (user === null) {
    return <p>Loading profile...</p>;
  }
  return (
    <>
      <h1>{user.name}</h1>
      <ProfileTimeline />
    </>
  );
}

function ProfileTimeline() {
  const [posts, setPosts] = useState(null);

  useEffect(() => {
    fetchPosts().then(p => setPosts(p));
  }, []);

  if (posts === null) {
    return <h2>Loading posts...</h2>;
  }
  return (
    <ul>
      {posts.map(post => (
        <li key={post.id}>{post.text}</li>
      ))}
    </ul>
  );
}

この場合、

  1. ユーザ詳細情報の取得を開始
  2. 待機する…
  3. ユーザ詳細情報の取得が完了
  4. タイムライン投稿 (posts) の取得を開始
  5. 待機する…
  6. 投稿の取得が完了

になり、並列化可能にもかかわらず意図せずシーケンスになっている状態

2: Fetch-Then-Render(サスペンス不使用)

両方のデータを同時に取得してから子コンポーネントに渡す

// Kick off fetching as early as possible
const promise = fetchProfileData();

function ProfilePage() {
  const [user, setUser] = useState(null);
  const [posts, setPosts] = useState(null);

  useEffect(() => {
    promise.then(data => {
      setUser(data.user);
      setPosts(data.posts);
    });
  }, []);

  if (user === null) {
    return <p>Loading profile...</p>;
  }
  return (
    <>
      <h1>{user.name}</h1>
      <ProfileTimeline posts={posts} />
    </>
  );
}

// The child doesn't trigger fetching anymore
function ProfileTimeline({ posts }) {
  if (posts === null) {
    return <h2>Loading posts...</h2>;
  }
  return (
    <ul>
      {posts.map(post => (
        <li key={post.id}>{post.text}</li>
      ))}
    </ul>
  );
}

この場合、

  1. ユーザ詳細情報の取得を開始
  2. タイムライン投稿の取得を開始
  3. 待機する…
  4. ユーザ詳細情報の取得が完了
  5. 投稿の取得が完了

になり、両方のデータが揃うまで画面が描画できない

3: Render-as-You-Fetch(サスペンスを使用)

これまでのアプローチ

  1. データ取得を開始
  2. データ取得が完了
  3. レンダーを開始

サスペンスを使った場合、最後の 2 つのステップが入れ替わる

  1. データ取得を開始
  2. レンダーを開始
  3. データ取得が完了
// This is not a Promise. It's a special object from our Suspense integration.
const resource = fetchProfileData();

function ProfilePage() {
  return (
    <Suspense fallback={<h1>Loading profile...</h1>}>
      <ProfileDetails />
      <Suspense fallback={<h1>Loading posts...</h1>}>
        <ProfileTimeline />
      </Suspense>
    </Suspense>
  );
}

function ProfileDetails() {
  // Try to read user info, although it might not have loaded yet
  const user = resource.user.read();
  return <h1>{user.name}</h1>;
}

function ProfileTimeline() {
  // Try to read posts, although they might not have loaded yet
  const posts = resource.posts.read();
  return (
    <ul>
      {posts.map(post => (
        <li key={post.id}>{post.text}</li>
      ))}
    </ul>
  );
}

fetchProfileData();でデータ取得が開始され

画面の をレンダーしたときに起こること

  1. レンダー時点で fetchProfileData() を使ってリクエストがスタート
  2. React は のレンダーを試みて、子要素として が返える
  3. React は のレンダーを試みて、内部で resource.user.read() が呼び出され、データはまだ何も取得されていないので、このコンポーネントは “サスペンド (suspend)” する
    React はこのコンポーネントを飛ばして、ツリーの他のコンポーネントのレンダーを試みる
  4. React は のレンダーを試みて、内部で resource.posts.read() が呼び出され、今回も、まだデータがないので、このコンポーネントは “サスペンド
    React はこのコンポーネントも飛ばして、ツリーの他のコンポーネントのレンダーを試みる
  5. レンダーを試みるべき他のコンポーネントは残っておらず、サスペンドしたので、React はツリーの直上にある フォールバックを表示

Concurrent Modeの説明で、Concurrent Modeはバージョンコントロールみたいに並行で処理を進めることができるといっていたのでこんな感じだろうか

(中の処理を表しているわけではなく、ただバージョンコントロールっぽく表した図) f:id:uggds:20200518234509p:plain

その他のメリット

  • if (...) による「ロード中か」のチェックが消えてコードがすっきりになる
  • ロードの順番の変更が容易(プロフィール詳細とタイムライン投稿が同時に「ぱっと」出現するようにしたくなったなら、その 2 つの間にある を取り除けばいい)になる