React useで非同期処理を簡単に扱う
先日 続・URLシェアを支える技術 CompressionStream という記事をZennに投稿しました。
この記事のためにlz-stringとCompressionStreamの圧縮率を比較できるサイト(以下比較サイト)をVite+Reactで作りました。
このサイトでは、入力してもらったテキストをURLに反映して、入力状態をそのままシェアできる機能を実装しています。TypeScript PlaygroundやReact Compiler Playgroundのような感じです。
例えば次のようなURLをシェアすることができます。
この機能を実現するに当たって、少し困ったけどReactのuse
で解決できたので、その実装方法を紹介します。
問題
ソースコード共有機能を実装するには、サイトが開かれたとき、URLのクエリパラメーターからソースコード部分を取得して、それを解凍してからテキストエディターに渡す必要があります。
URLのソースコード部分の圧縮・解凍には、せっかくなのでlz-stringではなく自作したCompressionStream版のcompressToEncodedURIComponent
/ decompressFromEncodedURIComponent
を使いたいと思いました。しかし、Zennの記事にも書いたように、lz-stringは同期処理なのに対してCompressionStreamは非同期処理です。
Reactは非同期処理した結果をステートとして扱うには、ひと工夫必要なのは周知の事実です。でもこの小さいサイトのためにTanstack QueryとかSWRとか使うのはオーバーキル感があるし、かといってuseEffect
で非同期処理してステートにセットするような原始的なやり方もしたくないなと思いました。
解決策
React 19からuse
というAPIが提供されます。これはPromise
の中身を取り出すかのような使い勝手となっています。
export function use<T>(usable: Usable<T>): T;
これを使えば、余計なライブラリを追加することなく、useEffect
を使うこともなく非同期処理の結果をコンポーネント内で扱うことができると考えました。
実装
Vite react-tsテンプレートのセットアップが完了しているものとします。
執筆時点でReactは18が最新メジャーバージョンなので、RCバージョンをインストールする必要があります。
npm install react@rc react-dom@rc
RC版で追加されるAPIの型定義をTypeScriptに認識させる必要があります。最新バージョンに含まれていないAPIの型定義を読み込むには、@types/react/canary
をどこかで読み込みます。Viteプロジェクトにはvite-env.d.ts
があるので、そこに相乗りしてしまいます。
// vite-env.d.ts
/// <reference types="vite/client" />
/// <reference types="react/canary" />
(React19アップグレードガイドに従うなら@types/react
をtypes-react@rc
で上書きするらしいが、なぜか上書きできなかった)
これでuse
がreact
パッケージからimportできるようになりました。続いてソースコードを書いていきます。
まず、クエリパラメーターからソースコードを取り出して解凍した結果を受け取るPromiseを作ります。
const defaultSource = "..."; // 省略
const defaultSourcePromise = useMemo<Promise<string>>(async () => {
const searchParams = new URLSearchParams(window.location.search);
const fromParams = searchParams.get("source");
if (!fromParams) return defaultSource;
try {
return await decompressFromEncodedURIComponent(fromParams);
} catch {
return defaultSource;
}
}, []);
SSR(サーバーサイドレンダリング)をしないViteプロジェクトなので、window
オブジェクトを直接参照しています。Next.jsだとエラーになるので注意してください。
useMemo
を使っているのは、Promise
の参照がなるべく変わらないようにするためです。厳密にはuseMemo
は参照を固定するために使うものではないのですが、雑に使っています。setterを無視したuseState
でもいいです。というかコンポーネント外の変数でもいいですね。
そして、use
を使ってPromise
から結果を取り出します。
const defaultSource = use(defaultSourcePromise);
とても単純ですね。まるでawait
しているかのようなシンプルさです。
ただしawait
とは明確に異なり、use
を実行するとコンポーネントがサスペンドします。つまりuse
を使っているコンポーネントはSuspense
でラップされる必要があります。
また、サスペンドすることは要するに一旦アンマウントされるということでもあり、use
に渡したPromise
がresolveしたとき関数コンポーネントは再度上から関数として実行されます。これはdefaultSourcePromise
を生成するコンポーネントとuse
で値を取り出すコンポーネントは別にしなければならないことを意味します。
なぜなら、Promise
オブジェクトにuseMemo
が使われているとしても、アンマウントされてしまえばその値は忘れられて再生成されるからです。Promise
が作り直しになればそれはまたresolveされていないPromise
となり、再度サスペンドされることになります。サスペンドすればまたPromise
が再生成され、、、という無限ループに陥ります。
以上を踏まえると、次のようなコンポーネントのツリー構造になります。
const App: React.FC = () => {
const defaultSourcePromise = useMemo<Promise<string>>(async () => {
const searchParams = new URLSearchParams(window.location.search);
const fromParams = searchParams.get("source");
if (!fromParams) return defaultSource;
try {
return await decompressFromEncodedURIComponent(fromParams);
} catch {
return defaultSource;
}
}, []);
return (
<Suspense>
<AppBody defaultSourcePromise={defaultSourcePromise} />
</Suspense>
);
};
export const AppBody: React.FC<{
defaultSourcePromise: Promise<string>;
}> = ({ defaultSourcePromise }) => {
const defaultSource = use(defaultSourcePromise);
return <>{/*defaultSource を使ってなんやかんやするコンポーネントたち*/}</>;
};
上位のコンポーネントでPromise
を生成し、下位のコンポーネントにprops経由で渡します。下位のコンポーネントはuse
を使ってpropsで受け取ったPromise
から値を取り出します。上位のコンポーネントは下位のコンポーネントをSuspense
でラップします。
Suspense
を忘れると上位のApp
までサスペンドすることになります。まるでtry-catchで捕捉されなかったエラーかのように、親コンポーネントを辿ってサスペンドさせていきます。
上のようなツリー構造にすれば、AppBody
の中でクエリパラメーターから取り出した解凍済みのソースコード(defaultSource
)が使えるようになるので、あとはテキストエディターに渡すなりなんなり自由に使えます。
おわり
React 19から提供されるuse
を使うことで、非同期処理の結果をコンポーネント内でシンプルに扱えるようになりました。
ただ、ぶっちゃけアプリケーションレイヤーで直接触るようなAPIではない気もします。ライブラリやフレームワークが内部で非同期処理とReactコンポーネントをつなぐためのAPIかなと推測しています。今回の比較サイトのような小さいサイトで、ちょっとした非同期処理を依存なしで扱う分には簡単でよいと思います。
非同期処理とは別に、use
にはReact Contextを直接読み込む機能もあります。できることはuseContext
と同じですが、use
は条件分岐やループの中でも使えるAPIなので、useContext
よりも使い所が多いでしょう。最早use
の登場でuseContext
の出番がなくなるまであります。
React 19の正式リリースが待ち遠しいですね。
それではよいReactライフを!