Subterranean Flower

ReactのSuspenseで非同期処理を乗りこなす

Author
古都こと
ふよんとえんよぷよぐやま!!!

Reactはどちらかというと非同期処理が苦手な部類でした。今まではReduxのmiddlewareを駆使したり、Hooksを上手く使ったりして乗り切っていました。

そこで以前よりSuspenseという機能の実装が進んでいます。Suspenseはまだ世間に浸透しきっていない機能ですが、Reactの世界を大きく変える可能性があります。そんなSuspenseについて、軽く覗いてみましょう。

Suspenseの世界

Reactで非同期処理を綺麗に扱うのは簡単なことではありません。redux-sagaを使うにせよ、useEffectを使うにせよ、大きな痛みを伴います。

そもそもReactはアプリケーションのUI層を担当するライブリラリです。本来果たすべき責務に注力できず非同期処理のような些事に気を取られ、あろうことか非同期処理がReactアプリケーションの設計に大きな影響力を持ち始めるというのは、望んでいません。

しかし現代において、非同期処理とReactアプリケーションを切り離すことはできないという現実が存在します。そのため我々は非同期処理から目を背けず、戦っていかなければなりません。

そこでReactが行き着いたひとつの答えがSuspenseです。Suspenseは非同期処理を無理やりReactの世界に引きずり込み、Reactの流儀で制御できるようになります。

const todoListResource = fetchTodoList();

const TodoList = () => {
  // ここでデータが読み込めているかどうかは気にしなくて良い
  const todoList = todoListResource.read();

  // 読み込めている前提でコードを書ける
  const items = todoList.map((todo) => <li>{todo}</li>);
  return <ul>{items}</ul>;
};

export const App = () => {
  // データが読み込めていない場合はSuspenseが面倒を見る
  // ここでは「Loading...」と表示する
  return (
    <Suspense fallback={<p>Loading...</p>}>
      <TodoList/>
    </Suspense>
  )
};

Suspenseを使うことで、非同期処理を宣言的・同期的に書くことができます。コンポーネントのあるべき姿を記述すればそれに従ってレンダリングされる、本来のReactのやり方で非同期処理を制御できます。

Suspenseはシンプルかつ堅牢な仕組みを提供します。Suspenseを使うことで多くの問題が解決するでしょう。

Error Boundary

Suspenseについて触れる前に、Error Boundary(エラー境界)について知らなければいけません。

Error Boundaryはコンポーネントでエラーが発生した場合にフォールバック(代替表示)を提供する仕組みです。

Reactのデフォルトではエラー発生時は画面が真っ白になります。これは、全てのコンポーネントが強制的にアンマウントされるためです。

エラー発生時の真っ白画面

しかしError Boundaryを設置することで任意のエラー表示を実現することが可能です。

Error Boundaryはクラスコンポーネントとして実装します。現時点ではHooks未対応です。

クラスコンポーネントにstatic getDerivedStateFromErrorメソッドを実装することで自動的にError Boundaryになります。通常時は子コンポーネントをそのまま表示して、エラー発生時は「エラー発生!」と表示するError Boundaryは以下のように作成します:

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  // このメソッドを実装する!
  // 与えられたエラーから新しいstateを返すメソッド
  static getDerivedStateFromError(error) {
    return { hasError: true };
  }

  render() {
    if(this.state.hasError) {
      return <p>エラー発生!</p>;
    }

    return this.props.children;
  }
}

このコンポーネントで通常のコンポーネントを囲ってやると、エラー発生時に「エラー発生!」と表示することができます。以下は故意にエラーを発生させるコンポーネントを作成してError Boundaryで囲っています:

const Invalid = () => {
  // nullのfooプロパティにアクセスしようとしてエラー
  // そんなプロパティは存在しない!
  return <p>{null.foo.bar}</p>;
}

export const App = () => {
  return (
    <ErrorBoundary>
      <Invalid/>
    </ErrorBoundary>
  )
};

これを実行してみると以下のような表示になります:

コンポーネント内部で発生したエラーをError Boundaryで捕らえることができました!Reactではこのようにしてエラーのハンドリングができるようになっています。

しかしError Boundary自体がエラーを発生させてしまうとどうしようもなくなるので、そこだけは注意しましょう。

Promise Boundary

エラーが発生(throw)したとき、ReactではError Boundaryを用いてエラーを捕まえられることがわかりました。

ここで話は少し逸れますが、JavaScriptではthrowする値に制限はありません。文字列をthrowすることもできますし、単なるオブジェクトもthrowできます。その気になればPromiseでもthrowできます。

Promiseをthrowできるということは、もし「Promise Boundary」なるものが存在したならば、Promiseを捕捉してPromiseの状態に応じたフォールバックを表示できるということになります。Promise Boundaryがあれば非同期処理の制御を任せてしまうことができるでしょう。

そのPromise Boundaryを実現したものがまさにSuspenseです。Suspenseの正体は、Promiseを捕捉するBoundaryだったのです。

Suspenseを利用する

Suspenseの利用方法は簡単です。Suspenseをreactからimportし、フォールバックしたいコンポーネントを<Suspense fallback={表示したいフォールバック}>で囲むだけです。

import { Suspense } from 'react';

const todoListResource = fetchTodoList();

const TodoList = () => {
  // ここでデータが読み込めているかどうかは気にしなくて良い
  const todoList = todoListResource.read();

  // 読み込めている前提でコードを書ける
  const items = todoList.map((todo) => <li>{todo}</li>);
  return <ul>{items}</ul>;
};

export const App = () => {
  // データが読み込めていない場合はSuspenseが面倒を見る
  // ここでは「Loading...」と表示する
  return (
    <Suspense fallback={<p>Loading...</p>}>
      <TodoList/>
    </Suspense>
  )
};

これだけで「Promise未解決時にはフォールバックを、解決時には実際のコンポーネントを表示」が実現できます。この例ではフォールバックに「Loading…」を、解決後のコンポーネントとしてはTodoListを表示しています。

ここで気になるのがfetchTodoList関数とread関数です。どちらもReactのものではなく、こちらで定義した関数です。read関数は「Promiseが未解決ならPromiseをthrowし、解決していれば値をreturnする関数」です。read関数のようなメソッドを持つオブジェクトを、React公式では「リソース(resource)」と呼んでいるようです。

このread関数の挙動により、Promiseが未解決の場合はPromiseがthrowされるのでコンポーネントの処理は中断し、Suspense(Promise Boundary)が捕捉してフォールバックを表示します。Promiseが解決している場合は何もthrowされず具体的な値がreturnされてデータが入手できるので、そのままコンポーネントがレンダリングされます。

ちょっと中身を見ていきましょう。

const fetchTodoList = () => {
  const promise = new Promise((resolve, reject) => {
    setTimeout(()=> {
      resolve([
        'Reactの復習',
        'Error Boundaryについて知る',
        'Suspenseについて勉強する'
      ]);
    }, 1000);
  });

  return wrapPromise(promise);
};

fetchTodoList関数は簡単なタイマーになっています。実際の通信の模擬的な再現ですね。1秒後に解決するPromiseを作っています。

Promiseをそのまま返すのではなくwrapPromiseという関数でラップしてから返しています。wrapPromise関数は以下のように実装しています:

const wrapPromise = (promise) => {
  let status = 'pending';
  let result;

  const suspender = promise.then(
    (r) => {
      status = 'fulfilled';
      result = r;
    },
    (e) => {
      status = 'rejected';
      result = e;
  });

  const read = () => {
    if(status === 'pending') {
      throw suspender;
    } else if(status === 'rejected') {
      throw result;
    } else {
      return result;
    }
  };

  return { read };
}

長ったらしいですが、要はPromiseから「リソース」への変換をかけているだけです。「リソース」はデータ読み出しのメソッド(ここではread)を持ち、readは「未解決ならPromiseをthrow、解決済みなら値をreturn」しています。この「リソース」を用いて処理を行うことで、Suspenseの仕組みの上に簡単に乗ることができます。

つまりreadメソッドを呼ぶことで初回はPromiseがthrowされ、Suspenseがそれを検知してフォールバックを表示します。そしてthrowされたPromiseが解決したらSuspenseは再びレンダリングを試み、再び実行されたreadメソッドは実際の値をreturnします。

これで「読み込み中なら”Loading…”を表示、読み込み済みならデータを表示」ということが実現できます。

Suspenseを利用した動的フェッチ

先ほどの例ではコンポーネントの外でリソースを作成していました。しかし現実にそんな場面はそうそう存在しません。何かしらのユーザインタラクションがあり、それに応じてリソースを作成するのが一般的なシナリオでしょう。

そういった場合はHooksなどでリソースを保持し、イベントハンドラでリソースを切り替えるという処理にすると良いでしょう。

const Preview = (props) => {
  const url = props.resource.previewImg.read();
  return <img src={url}/>;
};

export const App = () => {
  const [resource, setResource] = useState(fetchImageData('notselected'));

  return (
    <>
      <button onClick={() => setResource(fetchImageData('reactman'))}>
        Click Me!
      </button>
      
      <Suspense fallback={<p>Loading...</p>}>
        <Preview resource={resource}/>
      </Suspense>
    </>
  );
};

リソースを差し替えるだけなので、簡単に実現できます。

Redux等のステート管理ライブラリを使用しているならそちらに統合してもよいでしょう。単純にリソースをセットするだけなので、難しいことは考えなくても大丈夫です。

Suspenseの特性

早期フェッチ

Suspense自体の直接的な特性ではありませんが、Suspenseは早期にデータをフェッチしてその結果に応じてレンダリングする仕組みを推奨しています。実際に最初のfetchTodoListでの例でもコンポーネントの外でリソースを取得していましたし、動的フェッチの例でもリソースの入れ替えとフェッチは同時でした。

これはuseEffectを使用したレンダリング時フェッチとはまた違った特性をもたらします。

素直なレンダリングの仕組み

Suspenseはレンダリングの状態に依存しません。むしろレンダリングがPromiseの状態に依存するという逆転現象を起こすことができます。これにより「propsの値に応じて表示が変わる」という素朴なReactの世界観に非同期処理を持ち込むことができます。

従来のReactと非同期処理は相容れぬものであり、併用すると大きな問題が発生します。たとえばuseEffectを用いた非同期処理は、「ウォーターフォール」や「レースコンディション」と呼ばれる問題を引き起こしやすくなります。

ウォーターフォールの解消

「ウォーターフォール」はコンポーネントを階層構造にすることにより発生する問題です。useEffectの特性上、レンダリングが実行されて初めて副作用が実行されるので、本来並列に処理できるはずの処理がシーケンシャルに実行されることがあります。

例えば以下のようなコンポーネントがあるとします:

const UserProfile = () => {
  const [userProfile, setUserProfile] = useState(null);

  useEffect(() => {
    fetchUserProfile().then(setUserProfile);
  }, []);

  if(!userProfile) { return <p>Loading...</p>; }

  return (
    <>
      <div>{userProfile.username}</div>
      <Timeline/>
    </>
  );
};

useEffectを用いた簡単な非同期処理の例です。例えばfetchUserProfileに1秒かかるとしたら、1秒後にユーザ名とTimelineコンポーネントが表示されます。プログラム的には正しく、問題なく動作します。

しかしここでTimelineコンポーネントでも同じようなフェッチ処理をしていたらどうなるでしょう?useEffectはレンダリングのときに初めて動作します。つまりプロフィールが取得されてTimelineがレンダリングされて、そこでやっとTimelineのフェッチ処理が起動します。仮にタイムラインのフェッチに1秒かかるとしたら、プロフィールの取得とあわせて合計2秒かかることになります。

この問題を解決するには、親コンポーネント側でフェッチしPromise.allでまとめて取得するという手段が取れます。しかしこれは全てのPromiseを待つので、もし極端に長いPromiseがひとつでも混ざっていれば、その長いPromiseが解決するまで何もレンダリングされなくなります。

ならば、親コンポーネント側でバラバラにフェッチすれば良いかもしれません。しかしバラバラに取得するとデータの整合性を保証できなくなります。フェッチしたバラバラのデータに、今現在は何が入っているのか確認しないといけなくなります。

もちろん、より注意深く設計されたコンポーネントではこのような問題は起こりません。しかし人間は注意深い生き物ではありません。必ず問題は起こります。そして、そもそもそんなに注意したくありません。Reactの担当分野であるUIと直接関係ない非同期処理に振り回されるのは、あまりにも理不尽です。

Suspenseはこれを解決します。「早期フェッチ」の考えがあるのでデータのフェッチを試みてからレンダリングに移ります。あとはリソースからデータを読み出し表示するだけです。

const resource = {
  profile: wrapPromise(fetchUserProfile()),
  timeline: wrapPromise(fetchUserTimeline())
};

const UserTimeline = () => {
  const timeline = resource.timeline.read();

  return (
    <ul>
      {timeline.map((post) => <li key={post.id}>{post.content}</li>)}
    </ul>
  );
};

const UserProfile = () => {
  const userProfile = resource.profile.read();
  return <div>{userProfile.username}</div>;
};

export const App = () => {
  return (
    <Suspense fallback={<p>Loading...</p>}>
      <UserProfile/>
      <Suspense fallback={<p>Loading...</p>}>
        <UserTimeline/>
      </Suspense>
    </Suspense>
  );
};

きめ細かな表示制御が必要なら各所に<Suspense>を設置することになりますし、一度に全てを表示したい場合は単一の<Suspense>のみで囲ってしまえば纏めて表示することができます。

レースコンディションの解消

「レースコンディション(競合状態)」は複数の非同期処理が同時に走り、予期せぬ順序で解決することにより起こる異常な状態です。

例えばキー押下ごとにAPIへリクエストを投げるコンポーネントを考えます。このときリクエストはどういう順番で返ってくるかわからないので、前のリクエストの結果が後から返ってくることもあります。適切にハンドリングできていないと入力と表示の不一致を起こす可能性があります。

これはuseEffect内で適切にクリーンアップ関数をreturnすることにより完全に解決できます。

ですが、誰がクリーンアップ関数の実装漏れをチェックするのでしょうか。テストの方法はどうするのでしょうか。そして、その責務は本当にコンポーネントが負うべきなのでしょうか。

Suspenseではそもそもレースコンディションが発生しません。リクエストと同時にリソースを設定するので、確実に最新のリクエストであることを保証できます。

SuspenseとError Boundary

先述のwrapPromiseで作られたリソースはrejected時にエラーをthrowするので、Error Boundaryで捕捉することが可能です。

例えば以下のError Boundaryを使用します:

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error) {
    return { hasError: true };
  }

  render() {
    if(this.state.hasError) {
      return <p>エラー発生!</p>;
    }

    return this.props.children;
  }
}

故意にPromiseをrejectするリソースを作成します:

const fetchUser = () => {
  return wrapPromise(new Promise((resolve, reject) => {
    setTimeout(() => reject(), 1000);
  }));
};

このときError Boundaryで囲ってやれば、このエラーを捕捉することが可能です。

const userResource = fetchUser();

const UserProfile = () => {
  const user = userResource.read();
  return <div>{user.username}</div>
};

export const App = () => {
  return (
    <ErrorBoundary>
      <Suspense fallback={<p>Loading...</p>}>
        <UserProfile/>
      </Suspense>
    </ErrorBoundary>
  );
};

リクエスト時のエラーなどはError Boundaryで処理しましょう。

Concurrent Mode(並列モード)

Suspenseは現状でも非常に便利な機能ですが、将来的に導入されるConcurrent Mode(並列モード)ではより細かな制御ができるようになります。

Concurrent Modeでは複数のSuspenseの表示制御が一括でできる<SuspenseList>や、次の画面をバックグラウンドでレンダリングできるuseTransitionフックなど、新しい機能が盛り沢山です。

Concurrent Modeはまだ正式に実装されていないので実際の開発には使えませんが、興味があればReactのexperimental版に手を出してみると良いでしょう。

さいごに

Reactの世界にも様々な道具が増えてきました。そしてそれらのどれもが非常に強力で、現実的な問題に対しての解を示しています。いきなり色々な機能が増えて混乱するかもしれません。ですが、習得すれば強い武器となります。

Suspenseは永きにわたる「非同期処理との戦い」のチェックポイントのひとつです。今までのuseEffectによるプログラマ任せの脱出ハッチや、サードパーティのライブラリを用いた解決策とは一風違った方法を提供してくれます。

もしかしたら、Suspenseは多くの人には難しいかもしれません。Promiseをthrowする気持ち悪さやリソースオブジェクト作成の面倒さなど、受け入れられない点も多くあると思います。それでもSuspenseを使ってでも「Reactらしいコード」に非同期処理を押し込める意図や背景を、なんとなく雰囲気だけ掴んでくれればと考えています。

Reactはまだ発展途上のライブラリです。多くのプロダクトに使用されている一方で、多くの問題も抱えています。Reactの掲げる理想と現実のコードの間にはいまだにギャップが存在します。それらの問題は、いずれ解決していくでしょう。

それでは、ぜひReactを楽しんでください。