Open5

try suspense

ピン留めされたアイテム
msy.msy.

Susupenseとは

コンポーネントをサスペンドさせるにはPromiseをthrowする必要がある。
(Promiseを解決(settle)していない状態にする)

サンプルコード:
1秒後に解決(settle)されるPromiseを投げるコンポーネントを例に

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

const AlwaysSuspend: React.FC = () => {
  throw sleep(1000);
};

この状態だと画面が真っ白な状態が表示される。

サスペンドという状態(Promiseがthrowされている状態)はコンポーネントがレンダリングできない状態なので当たり前の状況ではある。
(この状態ではレンダリングが実行されていないので、レンダリング結果が存在しないので表示がないのは当然)

ちなみにサスペンドが発生すると、その該当するコンポーネントだけではなくその周辺まで巻き込んで表示されなくなる。

どうやらReactには表示の一貫性を保証するものがるらしく、ある瞬間にレンダリングが成功した(無事にレンダリングが行われた)コンポーネントツリーが部分的に表示されてしまうのを防ぐためっぽい(Uhyoさん曰く)

Reactでは、一部コンポーネントだけが表示される状況はない。
全部表示されるか、何も表示されないかの二択らしい。

Suspenseで使ってみる

上記の「レンダリングが実行されないため、表示が真っ白になる」という問題を解決するために、Prmiseを内部でthrowしているAlwaysSuspendコンポーネントをsuspenseで囲ってみる。

<div className="text-center">
  <h1 className="text-2xl">React App!</h1>
  <Suspense fallback={<p>Loading...</p>}>
   <AlwaysSuspend />
  </Suspense>  
</div>

上記のようにSuspenseを使うことで、コンポーネントツリーの中にレンダーの準備が整っていないコンポーネントが存在した場合に、画面を真っ白にするのではなく、何らかの代替のコンテンツを表示することができる。

※Suspenseの代替表示が適応されるのは、React.Suspenseで囲まれたコンポーネントツリーのみ

Susepenseコンポーネントで囲ったことで、別段内部的にレンダリングを実行してくれるわけではないが、サスペンドによる他のコンポーネントへの影響をとどめてくれる

逆に言えば、Suspenseの中でサスペンドが発生した場合、そのSuspenseの中は全部巻き込まれてレンダリングできなくなる

サスペンド状態でも表示させたいコンポーネントがある場合、Suspenseの外側に出すのが良い(?)

参考

React 旧公式ドキュメント React.Suspense

ピン留めされたアイテム
msy.msy.

コンポーネントの再レンダリングを確認する

const AlwaysSuspend: React.VFC = () => {
  console.log("AlwaysSuspend is rendered");
  throw sleep(1000);
};

上記のスクラップで作成したAlwaysSuispendコンポーネントは1秒ごとに解決されるPromiseをthrowしているわけだが、console.logを指し込んでみると1秒ごとにそのconsole.logが呼び出されているのがわかる。
しかも表示はLoading...のまま。orz

※これ違う
これはどういうことかというと、1秒ごとに再レンダリングが実行されいるが、最終的にPromiseがthrowされているのでそのレンダリングが失敗している。
ゆえにLoading...が変わらず表示され続けている。

今回のケースでは、Promiseが解決されていない間はSusepenseのfallbackに渡したLoading...の表示がなされるわけだが、今回でいうと、Promiseが一旦解決したタイミングでReactはサスペンドしたコンポーネントを際レンダリングする

がしかし、最終的にPromiseがthrowされたことでレンダリングがサスペンドされた状態になる(?)ため、Loading...という表示が出てくる。
内部的には、毎秒これ👆が繰り返されていることになる。

サスペンド状態の無限地獄に終止符を。

サスペンド状態の終了後の挙動を確認するために別のコンポーネントを用意する。

export const SometimesSuspend: React.VFC = () => {
  if (Math.random() < 0.5) {
    throw sleep(1000);
  }
  return <p>Hello, world!</p>;
};

50%の確率でレンダリングがサスペンド状態になるSometimesSuspendコンポーネントを用意。
このコンポーネントでは、50%の確率でPromiseをthrowさせるsleepメソッドが呼び出される。

逆を返せば50%の確率でコンポーネントのレンダリングが何事もなく完了する。
その場合は、当たり前だがリロードでもしない限りは特に再レンダリングが発生することはない

またサスペンド解除(最後にPromiseが解決した)時には、Suspenseコンポーネントの配下のコンポーネントツリーのみが再レンダリングされる。

ピン留めされたアイテム
msy.msy.

実際に非同期データ取得一緒にSuspense使ってみる

まずは、サンプルのフェッチング関数を用意する。
1秒後に"Hello"という文字列とランダムな数字を表示する。

useStateを導入(失敗例)

今回のデータのローディングを担当するコンポーネントに期待する挙動は以下の通り。

  • 1回目のレンダリングではデータのローディングを開始し、ローディングが完了したら解決されるPromiseをthrowする
  • 2回目のレンダリングでは、ローディングが完了したデータを表示する。

今回のケースではローディングされたデータをどこかで保持する必要がある。

まずは、よくありがちなuseStateにデータを保持させるやり方でやってみる。
(失敗するらしいが、とりあえずやってみる。)

下記のようなローディングコンポーネント(ローディングが完了したらデータを表示する)を用意する。

const DataLoader: React.VFC = () => {
  const [data, setData] = useState<string | null>(null);
  // dataがまだ無ければローディングを開始する
  if (data === null) {
    throw fetchData1().then(() => setData("sample text"));
  }
  // データがあればそれを表示
  return <div>Data is {data}</div>;
};


このコンポーネントの表示結果は、Loading...と一生表示されるだけとなる。
そしてコンソールに下記のwarningメッセージが表示される。

Warning: Can't perform a React state update on a component that hasn't mounted yet. This indicates
that you have a side-effect in your render function that asynchronously later calls tries to update the
component. Move this work to useEffect instead.

どうやら「マウントされていないコンポーネントでステートの更新はできんよ」ということが書いてあるっぽい。

// functionAはただ単に文字列を返す関数
throw functionA().then(() => setState(""))

【上記のコードの個人的な解釈】

おそらく、非同期のfetch関数が呼び出されて、内部でPromiseが解決されthenが呼び出される。
thenの中で値を更新するも、最終的にthrowされることでレンダリングがサスペンドされる(?)
その結果レンダリングが完了していないのにも関わらずsetStateによりstateが更新されているため、warningメッセージが出てきた?????

throwが行われているが、suspenseがthrowによってレンダリングサスペンドになったことを検知してLoading....の表示をやってくれてる。

みたいなのがおそらく今回のコードの一連な気がする。(圧倒的個人の見解)

どうやらReactではレンダリング以前は、stateの記憶領域が用意されてないっぽい。

Reactでは特定のコンポーネントがサスペンドした(レンダリングが中断された)場合、「そのコンポーネントがレンダリングを試みた」という記録そのものが消えてしまう。
非同期Fetch処理などをレンダリング前に実行したりすると、コンポーネントがサスペンドした場合、「レンダリングはないけど副作用だけが残る」という現象が起こるためReactではよく「レンダリング中に副作用を起こしてはいけない」と言われるらしい。

ちなみに、useStateだけでなくuseRefを使ってもコンポーネント内にデータを保持することはできない。
フック用の記憶領域はすべてレンダリングが完了しないと用意されない。

msy.msy.

【番外編】ちょっとそれたメモ

Suspenseとは

要するSuspenseってコンポーネント内でif (isLoading) return <>Loading....</>みたいにするやつを、より宣言的に書けるようにしたってだけで、基本的にはやってることはほとんど一緒。

Promiseをthrowするとは、一体?!

uhyoさんのQiitaによると、Promiseをthrowすると、レンダリングをサスペンドした状態になる。
すなわちPromiseが解決されるまで、レンダリングができないことを表す

参考

uhyo's Qiita article

サスペンド状態の解除

なんらかのトリガーによる再レンダリングでサスペンド状態が終了する
トリガー:state更新、Promise解決 etc….

msy.msy.

マウント(レンダリング完了 & フック用の記憶領域が準備できた)時にサスペンドさせてみる

サンプルコード
ボタンをクリックしたら、サスペンド状態になるようにした。

const DataLoader: React.VFC = () => {
  const [loading, setLoading] = useState(false);
  const [data, setData] = useState<string | null>(null);
  // ローディングフラグが立っていてdataがまだ無ければローディングを開始する
  if (loading && data === null) {
    throw fetchData1().then(setData);
  }
  // データがあればそれを表示
  return (
    <div>
      <div>Data is {data}</div>
      <button className="border p-1" onClick={() => setLoading(true)}>
        load
      </button>
    </div>
  );
};