🧊

React Suspenseで不要な描画処理をなくす

2021/11/24に公開

React Freeze が、Suspenseのユニークな利用方法を提案をしていたので記事にまとめました。

https://github.com/software-mansion-labs/react-freeze

おさらい: React Suspense とは

React Suspense は React 16.6から実験的に追加されたコンポーネントで、これによって、ロード状態であることを宣言的に指定できるようになります。

https://ja.reactjs.org/docs/concurrent-mode-suspense.html

Suspenseは、データの読み出しが準備できているかどうかをReactに伝える仕組みであるとドキュメントには書かれており、データ取得の際に使うことが主なユースケースと考えられます。

const ProfilePage = React.lazy(() => import('./ProfilePage')); // Lazy-loaded

// Show a spinner while the profile is loading
<Suspense fallback={<Spinner />}>
  <ProfilePage />
</Suspense>

データが呼び出し中であるかどうかは、Suspenseの子コンポーネント内で promise を throw することで、React に伝えることができます。

function ProfileDetails() {
  const user = resource.user.read();  // throw promise here
  return <h1>{user.name}</h1>;
}

この仕様自体、それだけで記事になるほど面白いです。(ここでは割愛します。)

https://overreacted.io/ja/algebraic-effects-for-the-rest-of-us/

https://qiita.com/uhyo/items/255760315ca61544fe33

React Freeze

React Freezeは、そんなSuspenseを利用したライブラリです。

元々React側は、データ取得の際に使うことが主なユースケースと言っていたのに対して、このライブラリの目的は、ある瞬間にユーザーに表示されていないアプリの部分について、不要な再レンダリングを避けることとなっています。

どうやって実現しているのでしょうか?
実は、ライブラリの実装もシンプルで興味深いです。

// ref: https://github.com/software-mansion-labs/react-freeze/blob/main/src/index.tsx
function Suspender({
  freeze,
  children,
}: {
  freeze: boolean;
  children: React.ReactNode;
}) {
  const promiseCache = useRef<StorageRef>({}).current;
  if (freeze && !promiseCache.promise) {
    promiseCache.promise = new Promise((resolve) => {
      promiseCache.resolve = resolve;
    });
    throw promiseCache.promise;
  } else if (freeze) {
    throw promiseCache.promise;
  } else if (promiseCache.promise) {
    promiseCache.resolve!();
    promiseCache.promise = undefined;
  }

  return <Fragment>{children}</Fragment>;
}

export function Freeze({ freeze, children, placeholder = null }: Props) {
  return (
    <Suspense fallback={placeholder}>
      <Suspender freeze={freeze}>{children}</Suspender>
    </Suspense>
  );
}

注目は、Suspenderコンポーネントにある、promiseCacheの実装部分で、freeze propsがtrueである間(freeze中)、空のpromiseをthrowし続けます。そして、Suspenderがpromiseをthrowし続けている間、Freezeコンポーネント内のSuspenseによってそれを監視する仕組みになっています。

この実装の面白いところは、freeze中はSuspenseにより子コンポーネントが再描画せれず、代わりにSuspenseのfallbackを描画し、不要な描画処理をスキップしていることです。

また、Suspenseによって置き換えられた子コンポーネントは、アンマウントされないという性質があり、これによって、状態(ここでいう状態はstateではなく、スクロールの位置や、input、ロードされた画像など)を保持したまま再描画を抑制することが可能になります。

このライブラリが主に想定しているユースケースは、アプリのスタック型のナビゲーションであり、このSuspenseの性質を利用することでStackの一番上にあるコンポーネント以外をfreezeすることで、効率良く画面の状態をロックすることができます。

考察

React Freezeの、アンマウントせずに画面をフリーズされる機能によって、Native/Webでの画面の最適化の方法の一つとしてのSuspenseを利用を考えられそうです。実装自体がシンプルなので、色々と応用ができそうだと感じました。

スタックビューがメインのユースケースとして想定されていますが、Webでの利用についても少し言及されており、例えばモーダルなど画面を覆うようなコンポーネントで利用できそうです。

function App() {
  const [showModal, setShowModal] = useState(false)
  return (
    <Fragment>
      <Freeze freeze={showModal}>
        <AppPage onOpenModal={() => setShowModal(true)} />
      </Freeze>
      <Freeze freeze={!showModal}>
        <AppModal open={showModal} />
      </Freeze>
    </Fragment>
  );
}

また、React Freezeによって、データの取得時の状態以外の用途でもSuspenseが使える可能性が示唆されたとも言えます。

データの読み出しが準備できているかどうかをReactに伝える仕組みであるというのが当初のSuspenseを提供する目的だと思っていましたが、それだけでなく、コンポーネントが描画可能な状態であることを伝える仕組みだと、より一般化して捉えられると思いました。

もしかしたら、React Suspenseの可能性はもっと大きいのかもしれないですね。

Discussion