非同期レンダリング
| 対応: | React 18(2022) |
|---|
『React』の非同期レンダリング(Async Rendering)は、レンダリング処理を中断・再開可能にすることで、UIの応答性を向上させる仕組みです。React 18 で導入された「Concurrent(コンカレント)モード」がその中核を担っており、ユーザー入力などの緊急度の高い処理を優先しつつ、重い描画を後回しにすることで、アプリが固まって見える現象を防ぎます。
概要
従来の React(React 17 以前)のレンダリングは同期的であり、一度開始したレンダリングは完了するまで中断できませんでした。コンポーネントツリーが大きい場合、レンダリング中はブラウザがユーザー操作に応答できず、UIがカクついて見えることがありました。
React 18 が提供する Concurrent モードでは、レンダリングを細かい単位に分割して処理します。優先度の高いタスク(キー入力・クリックなど)が発生した場合、進行中のレンダリングを一時中断し、そちらを先に処理してからレンダリングを再開します。これにより、アプリが応答し続けているように感じられます。
| 概念 | 概要 |
|---|---|
| Concurrent モード | React 18 以降で有効になるレンダリングモードです。『createRoot()』を使ってマウントすることで自動的に有効になります。 |
| レンダリングの中断・再開 | 優先度の低い更新を中断し、緊急度の高いタスクを先に処理してから再開します。 |
| 優先度付き更新 | すべての状態更新が同じ優先度ではなく、ユーザー操作による更新は高優先度、データ再描画などは低優先度として扱われます。 |
| フォールバック UI | 非同期コンポーネントや遅延読み込みの待機中に、『Suspense』を用いて代替UIを表示できます。 |
Concurrent モードの有効化
React 18 では『createRoot()』を使うことで Concurrent モードが有効になります。従来の『ReactDOM.render()』では同期モードのままです。
// React 18 以降の推奨マウント方法
// createRoot() を使うことで Concurrent モードが自動的に有効になります
import { createRoot } from 'react-dom/client';
import App from './App';
// ルートコンテナを取得します
const container = document.getElementById('root');
// createRoot でルートを作成します(Concurrent モード有効化)
const root = createRoot(container);
// App をレンダリングします
root.render(<App />);
サンプルコード
検索フィールドへの入力(高優先度)と、重い検索結果リストの描画(低優先度)を分離することで、入力中もUIが滑らかに動き続ける例です。
import { useState, useTransition, useDeferredValue } from 'react';
// 重い一覧コンポーネント
// query が変わるたびに大量のアイテムを描画します
function HeavyList({ query }) {
// 10,000件分のアイテムを生成して描画します(重い処理を模擬)
const items = Array.from({ length: 10000 }, function(_, i) {
return 'アイテム ' + (i + 1);
});
// query でフィルタリングします
const filtered = items.filter(function(item) {
return item.includes(query);
});
return (
<ul>
{filtered.map(function(item) {
return <li key={item}>{item}</li>;
})}
</ul>
);
}
// App コンポーネント(ルートコンポーネント)
function App() {
// 入力フィールドの値(高優先度で即時更新)
const [input, setInput] = useState('');
// useTransition を使って低優先度の更新を管理します
// isPending: 低優先度の処理が進行中かどうかを示します
const [isPending, startTransition] = useTransition();
// 低優先度で更新される検索クエリ
const [query, setQuery] = useState('');
function handleChange(e) {
// 入力値は即時(高優先度)で更新します
setInput(e.target.value);
// 重い描画を伴う query の更新は低優先度(Transition)として扱います
// これにより入力操作が妨げられません
startTransition(function() {
setQuery(e.target.value);
});
}
return (
<div>
<input
value={input}
onChange={handleChange}
placeholder="検索キーワードを入力"
/>
{/* 低優先度の処理が進行中は「検索中...」と表示します */}
{isPending && <p>検索中...</p>}
{/* query が確定した値で HeavyList を描画します */}
<HeavyList query={query} />
</div>
);
}
export default App;
非同期レンダリングを支える主なAPI
| API | 概要 |
|---|---|
| createRoot() | Concurrent モードを有効化するためのルート作成関数です。React 18 以降の推奨マウント方法です。 |
| useTransition | 状態更新を低優先度(Transition)としてマークするフックです。進行中かどうかを示す『isPending』フラグも提供します。 |
| startTransition() | フックを使わずにトランジションを開始する関数です。コンポーネント外からも使用できます。 |
| useDeferredValue | 値の更新を遅延させて、高優先度の更新を先に反映させるフックです。 |
| Suspense | 非同期コンポーネントやコード分割の読み込み中に、フォールバックUIを表示するコンポーネントです。 |
| React.lazy() | コンポーネントを動的インポートで遅延読み込みする関数です。『Suspense』と組み合わせて使います。 |
補足
Concurrent モードは React 18 以降で『createRoot()』を使うだけで自動的に有効になります。特別な設定ファイルや追加パッケージは不要です。
非同期レンダリングの恩恵を最大限に活かすには、重い描画を伴う更新を useTransition や useDeferredValue で低優先度に分類することが重要です。すべての更新を高優先度のままにすると、同期レンダリングと変わらない動作になります。
また、React.memo や useMemo を組み合わせて不要な再レンダリングを抑制すると、より効果的にパフォーマンスを改善できます。
関連ページ: createRoot() / useTransition / useDeferredValue / Suspense
よくあるミス
ReactDOM.render() から createRoot() への移行忘れ
React 18 では『ReactDOM.render()』は非推奨となり、『createRoot()』に移行する必要があります。『ReactDOM.render()』を使い続けると Concurrent モードが無効のままになり、『useTransition』などの新機能の恩恵を受けられません。
index_ng.jsx
import ReactDOM from 'react-dom';
import App from './App';
// React 18 では非推奨。Concurrent モードが有効になりません
ReactDOM.render(<App />, document.getElementById('root'));
修正後:
index_ok.jsx
import { createRoot } from 'react-dom/client';
import App from './App';
// createRoot を使うことで Concurrent モードが有効になります
const root = createRoot(document.getElementById('root'));
root.render(<App />);
useTransition で即時更新すべき操作を遅延させてしまう
フォームの送信やエラー表示など即座に反映すべき更新を『startTransition』で囲むと、UI の応答性が悪くなります。『startTransition』に渡すのは「重い描画を伴う低優先度の更新」のみにします。
form_ng.jsx
function LoginForm() {
const [isPending, startTransition] = useTransition();
const [error, setError] = useState('');
function handleSubmit(e) {
e.preventDefault();
startTransition(function() {
// エラーメッセージは即時表示すべきなので startTransition に入れるのは不適切です
setError('パスワードが違います');
});
}
return (
<form onSubmit={handleSubmit}>
{error && <p>{error}</p>}
<button type="submit">ログイン</button>
</form>
);
}
修正後:
form_ok.jsx
function LoginForm() {
const [error, setError] = useState('');
function handleSubmit(e) {
e.preventDefault();
// エラー表示は高優先度なので startTransition を使わず直接更新します
setError('パスワードが違います');
}
return (
<form onSubmit={handleSubmit}>
{error && <p>{error}</p>}
<button type="submit">ログイン</button>
</form>
);
}
Suspense の fallback が表示されない
『Suspense』で囲んでいても、コンポーネントが Promise を throw していない場合(通常のレンダリング)は『fallback』が表示されません。『fallback』が機能するのは、子コンポーネントがデータ取得中などに Promise を throw したときだけです。
suspense_ng.jsx
// このコンポーネントは Promise を throw しないため Suspense の fallback は表示されません
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(function() {
fetch('/api/users/' + userId).then(function(r) { return r.json(); }).then(setUser);
}, [userId]);
if (!user) return <p>読み込み中...</p>;
return <p>{user.name}</p>;
}
// Suspense で囲んでも fallback="Loading..." は一切表示されません
function App() {
return (
<Suspense fallback={<p>Loading...</p>}>
<UserProfile userId={1} />
</Suspense>
);
}
修正後:
suspense_ok.jsx
// React.lazy と動的インポートを組み合わせることで Suspense が機能します
const LazyProfile = React.lazy(function() { return import('./UserProfile'); });
function App() {
return (
// LazyProfile の読み込み中に fallback が表示されます
<Suspense fallback={<p>Loading...</p>}>
<LazyProfile userId={1} />
</Suspense>
);
}
記事の間違いや著作権の侵害等ございましたらお手数ですがこちらまでご連絡頂ければ幸いです。