📘

React Suspenseをふわっと理解

2022/06/15に公開

サスペンスとは

  • 機能自体はReact 16.6(2018年10月リリース)からあった
    • Facebookが自社サイトの抱える問題を解決するために開発した
    • 正しく使えば、より良いユーザー体験を提供できる
  • コンポーネントを「ローディング中なのでまだレンダリングできない」という状態にすることができる

使い所

例: 一覧ページTaskList.tsxにおいて、APIからデータ取得中はローディングを表示し、取得完了したらコンテンツを表示したい

典型的な書き方

各状態に応じてreturnするものを分ける

const TaskList = () => {
 const { isLoading, error, data } = fetchTasks()
 // 取得中ならローディング表示
 if (isLoading) {
  return <p>取得中...</p>
 }
 
 // 取得失敗ならエラー表示
 if (error) {
  return <p>取得失敗!</p>
 }
 
 // 成功したらコンテンツ表示
 return <Contents data={data} />
}

Suspenseコンポーネントを使った書き方

データは既にあるものとして書く

const TaskList = () => {
  const data = getTasksOrThrow()
  
  return <Contents data={data} />
}
データ取得中のハンドリングは、Suspenseコンポーネントが担当
import { Suspense } from "react"

const App = () => {
  return (
    <Suspense fallback={<p>取得中....</p>}>
      <TaskList />
    </Suspense>
  )
}

テキストなり、アニメーションなりの代替表示したい要素をSuspenseコンポーネントのfallbackに渡すと、データ未取得の間それを表示してくれる

前者の場合: 取得中・失敗・成功のいずれの場合もTaskList.tsxをレンダリングする
後者の場合: 最初にTaskList.tsxをレンダリングしようとして、データ取得中であることを補足しレンダリングを中断する(コンポーネントがレンダリングされる頃にはデータ取得が完了してるので、宣言的に書ける)

サスペンスの仕組み

  1. getTodoListOrThrow()は、呼び出されたとき内部でリクエストを送信し、すぐさまPromiseをthrowする
  2. それを一番近くのSuspenseコンポーネントがcatchし、ローディングを表示(このとき、TodoList.tsxは「サスペンドした」という)
  3. getTodoListOrThrow()は、Promiseがresolveされると、内部でレスポンスデータをキャッシュする
  4. 次にgetTodoListOrThrow()が呼ばれたとき、キャッシュがあればそれを返す
throw とは

https://developer.mozilla.org/

  • throw 文は、ユーザー定義の例外を発生させます
  • 現在の関数の実行は停止し (throw の後の文は実行されません)、コールスタック内の最初の catch ブロックに制御を移します
  • 呼び出し元の関数に catch ブロックが存在しない場合は、プログラムが終了します。

例えばSuspenseをこんなふうに配置した場合、

<Suspense fallback={<PageGlimmer />}>
  <RightColumn>
    <ProfileHeader />
  </RightColumn>
  <LeftColumn>
    <Suspense fallback={<LeftColumnGlimmer />}>
      <Comments />
      <Photos />
    </Suspense>
  </LeftColumn>
</Suspense>
  • ProfileHeader.tsxがロード中のとき
    • ページ全体に`PageGlimmerアニメーションが表示される
  • Comments.tsxまたはPhotos.tsxがロード中のとき
    • ページの左半分にLeftColumnGlimmerアニメーション`が表示される

getTodoListOrThrow()のような、関数を、Suspenseで利用されることを前提に実装されたオブジェクトをサスペンスデータソースという

エラー時のハンドリングは、Error Boudaryコンポーネントに任せる

エラーバウンダリとは、コンポーネントツリーのどこかで例外が発生したとき、アプリケーション全体が影響を受けないようにするための機構のこと

Errorをthrowする可能性があるコンポーネントをラップするように配置する

<ErrorBoundary>
 <App />
</ErrorBoundary/>
import React, { Component } from "react"

export default class ErrorBoundary extends Compoent {
 state = { hasError: false }
 
 // ツリー配下のコンポーネントがErrorをthrowしたら呼び出されるライフサイクルメソッド
 // エラーオブジェクトを受け取り、stateを更新するための値を返す
 static getDerivedStateFromError(error) {
  return { hasError: true }
 }
 
 render() {
  const { hasError } = this.state
  const { children, fallback } = this.props
  
  // Errorがthrowされた場合
  if (hasError) {
   return  <p>取得失敗!</p>
  }
  
  return children
 }
}
  1. Errorがthrowされたら、getDerivedStateFromError()がそれを検知しステートを更新
  2. ステート更新によって再レンダリングされるとき、if文に沿ってフォールバックを描画する
ここでのgetTodoListOrThrow()の中身に関する詳細

この記事が分かりやすかったです
Promiseをthrowするのはなぜ天才的デザインなのか - @uhyo

Suspenseを使った速度向上の例

Suspenseを用いてrender-as-you-fetchパターンを実装する
→データ取得完了がレンダリングをトリガーする = 準備ができたコンポーネントからすぐレンダリング

fetch-on-render

下記のような場合、Todos.tsxを表示するのに必要なデータ取得と、Tasks.tsxを表示するのに必要なデータ取得は、それぞれ並行実行可能
しかし、後者は必ず前者の完了後に行われる(ウォーターフォール問題と呼ばれるアンチパターン)

const App = () => {
 const [todos, setTodos] = useState(null)
 
 useEffect(() => {
  fetchTodos().then(todos => setTodos(todos)
 }, [])

 if (!todos) {
  return <p>Todoリストをロード中...</p>
 }

 return (
  <div>
   <Todos data={todos} />
   <Tasks /> // このコンポーネントも内部でデータ取得`fetchTasks()`を行うとする
  </div>
 )
}
fetch-then-render

この場合、fetchTodos()fetchTasks()は同時にデータ取得を開始するため、ウォーターフォール問題は解決する

// Appコンポーネントの外側で、同時にリクエストを送信する
const promise = fetchData()

const App = () => {
 const [todos, setTodos] = useState(null)
 const [tasks, setTasks] = useState(null)

 useEffect(() => {
  promise().then(data => {
   setTodos(data.todos)
   setTasks(data.tasks)
  }
 }, [])
 
 if (!todos) return <p>Todoリストをロード中...</p>
 
 return (
  <div>
   <Todos data={todos} />
   <Tasks data={tasks} />
  </div>
 )
}

しかし、fetchData()関数の中身を以下のようなものだと仮定すると別の問題が生じる

function fetchDate() {
  return Promise.all([fetchTodos(), fetchTasks()])
    .then(([todos, tasks]) => ({ todos, tasks }))
}

Promise.allは、配列の全てのPromiseがresolveされるとresolveされる

Promise.all()

https://developer.mozilla.org/
Promise.all() メソッドは入力としてプロミスの集合の反復可能オブジェクトを取り、入力したプロミスの 集合の結果の配列に解決される単一の Promise を返します。この返却されたプロミスは、入力したプロミスがすべて解決されるか、入力した反復可能オブジェクトにプロミスが含まれていない場合に解決されます。

render-as-you-fetch
const data = fetchData()

const App = () => (
 <>
  <Suspense fallback={<p>Todosをレンダリング中...</p>}>
   <Todos />
  </Suspense>
  <Suspense fallback={<p>Tasksをレンダリング中...</p>}>
   <Tasks />
  </Suspense>
 </>
)

const Todos = () => {
 const todos = data.todos.getOrThrow()
 // todosをmapしてレンダリングする処理
}

const Tasks = () => {
 const tasks = data.tasks.getOrThrow()
 // tasksをmapしてレンダリングする処理
}

まとめ

  • Promiseをthrowすることで「ローディング中であること」を表現してるところが新しい
  • サスペンスを利用することで、「データが揃ったコンポーネントからすぐレンダリング」が可能になる
結局サスペンスデータソースって何...!?

引用: ReactのSuspense対応非同期処理を手書きするハンズオン

このクラスはnew Loadable(なんらかのPromise)のように使います。Loadableの内部でステート(#state)が管理され、Promiseが解決(成功または失敗)するとそのことを記録します。Promise本体とは別に管理することで、必要な場合に同期的に内容を取得できるようにします。その際に使うのがgetOrThrowメソッドで、Suspenseでの利用を見越した実装になっています。

getOrThrowメソッドはラップされたPromiseが成功裏に解決済の場合はその値を返します。それ以外の場合、Promiseが失敗した場合はそのエラーを投げます。そして、まだ解決していない場合はPromiseを投げます。

type LoadableState<T> =
  | {
      status: "pending";
      promise: Promise<T>;
    }
  | {
      status: "fulfilled";
      data: T;
    }
  | {
      status: "rejected";
      error: unknown;
    };

// このオブジェクトがサスペンスデータソース
export class Loadable<T> {
  #state: LoadableState<T>;
  constructor(promise: Promise<T>) {
    this.#state = {
      status: "pending",
      promise: promise.then(
        (data) => {
          this.#state = {
            status: "fulfilled",
            data,
          };
          return data;
        },
        (error) => {
          this.#state = {
            status: "rejected",
            error,
          };
          throw error;
        }
      ),
    };
  }
  getOrThrow(): T {
    switch (this.#state.status) {
      case "pending":
        throw this.#state.promise;
      case "fulfilled":
        return this.#state.data;
      case "rejected":
        throw this.#state.error;
    }
  }
}

Discussion