はじめに
React 18 の useTransition + Suspense を使うことでReactの並列レンダリング(concurrent rendering)機能を活用し、ユーザー体験を損なわないスムーズなUI更新を実現する機能を試すことのできるコードを紹介します。
ポイント: startTransition を使った並列処理
/*
* useTransition() で「重い処理は後でやる」宣言ができる。
* startTransition(() => {...}) で囲まれた処理は「低優先度」で実行される。
* → ユーザーのクリックなどの高優先度の処理を先に行う。
*/
const [isPending, startTransition] = useTransition();
function updateFilter(newFilter){
// UIの状態は即座に変える(見た目が速い)
setFilter(newFilter);
// 重いデータ取得は後から、しかも遅延してレンダリング
startTransition(() => {
fetchData(newFilter);
});
}
/*
* フィルターの変更は即時 (setFilter)
* データ取得は startTransition() の中 → UIの応答性を維持
*/
以下のようなユースケースで活躍しそうです。
・フィルター切り替え、検索結果表示、タブ切替など、操作直後にUIの反応が求められる処理
・重たいデータフェッチや描画 を「非同期にしたいけれど、入力はすぐに反映したい 」場面
・React 18以降を使っている環境
サンプル
App.tsx
import React, { useState, useTransition, Suspense } from "react";
// フェイクAPI:フィルターに応じたデータ取得
function fetchData(filter: FilterType): Promise<string[]> {
return new Promise((resolve) => {
setTimeout(() => {
const data =
filter === "favorites"
? ["りんご", "バナナ"]
: ["りんご", "バナナ", "みかん", "ぶどう"];
resolve(data);
}, 1000);
});
}
// 型定義
type FilterType = "all" | "favorites";
// サスペンス対応のラッパー
function createResource<T>(promise: Promise<T>) {
let status: "pending" | "success" | "error" = "pending";
let result: T;
let error: any;
const suspender = promise.then(
(res) => {
status = "success";
result = res;
},
(err) => {
status = "error";
error = err;
}
);
return {
read(): T {
if (status === "pending") {
throw suspender;
} else if (status === "error") {
throw error;
} else {
return result!;
}
},
};
}
// グローバルリソース
let resource = createResource(fetchData("all"));
// フィルター更新に応じてリソースを再生成
function loadResource(filter: FilterType) {
resource = createResource(fetchData(filter));
}
// 結果表示コンポーネント
function Results({ filter }: { filter: FilterType }) {
const items = resource.read();
return (
<ul>
{items.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
);
}
// メインコンポーネント
export default function App() {
const [filter, setFilter] = useState<FilterType>("all");
const [isPending, startTransition] = useTransition();
function updateFilter(newFilter: FilterType) {
setFilter(newFilter);
startTransition(() => {
loadResource(newFilter);
});
}
return (
<div className="dashboard">
<nav className="filter-bar">
<button
className={filter === "all" ? "active" : ""}
onClick={() => updateFilter("all")}
>
すべて
</button>
<button
className={filter === "favorites" ? "active" : ""}
onClick={() => updateFilter("favorites")}
>
お気に入り
</button>
{isPending && <span className="loading-indicator">更新中...</span>}
</nav>
<div className="content">
<Suspense fallback={<div className="skeleton-loader">読み込み中...</div>}>
<Results filter={filter} />
</Suspense>
</div>
</div>
);
}
スタイル(お好みで)
App.css
.dashboard {
font-family: sans-serif;
padding: 20px;
}
.filter-bar {
margin-bottom: 10px;
}
button {
margin-right: 10px;
}
.active {
font-weight: bold;
}
.loading-indicator {
margin-left: 10px;
color: blue;
}
.skeleton-loader {
opacity: 0.6;
}
実行方法(Vite)
npm create vite@latest my-app -- --template react-ts
cd my-app
npm install
npm run dev