Reactを使っていると、「フォーム送信したらすぐに結果を表示したい」「ネットワークの遅延を感じさせないUI体験を提供したい」という課題に直面します。useOptimistic は、非同期処理の完了を待たずに UI を先に更新する「楽観的更新(Optimistic Update)」を実現するための React 19 で追加された新しいフックです。
楽観的更新(Optimistic Update)とは
サーバーへのリクエストが成功すると「楽観的に」仮定し、サーバーからのレスポンスを待たずに UI を先に更新するパターンです。成功した場合はそのまま表示を維持し、失敗した場合は元の状態に戻します。これにより、ユーザーにとって高速で応答性の高いアプリケーション体験を提供できます。
1. なぜ useOptimistic が必要か
1.1 従来のアプローチの問題点
従来、フォーム送信などの非同期処理では、以下のような流れが一般的でした:
// ❌ 従来のアプローチ:レスポンスを待ってから更新
function MessageForm() {
const [messages, setMessages] = useState([]);
const [isLoading, setIsLoading] = useState(false);
async function handleSubmit(text) {
setIsLoading(true);
try {
const newMessage = await sendMessage(text); // ネットワーク待ち
setMessages([...messages, newMessage]); // 完了後に更新
} finally {
setIsLoading(false);
}
}
return (
<>
{isLoading && <span>Sending...</span>}
{messages.map(msg => <p key={msg.id}>{msg.text}</p>)}
</>
);
}
この方法の問題点:
- ユーザー体験が悪い: ネットワーク遅延があると、送信ボタンを押してからメッセージが表示されるまで時間がかかる
-
複雑な状態管理:
isLoadingなどの追加の state が必要 - リアクティブでない: 最新の UI 状態を即座に反映できない
1.2 useOptimistic による解決
useOptimistic を使うと、非同期処理中に「仮の値」を表示し、処理完了後に実際の値に置き換えることができます:
// ✅ useOptimistic:即座に仮の値を表示
function MessageForm() {
const [messages, setMessages] = useState([]);
const [optimisticMessages, addOptimisticMessage] = useOptimistic(
messages,
(currentMessages, newMessage) => [
...currentMessages,
{ text: newMessage, sending: true }
]
);
async function handleSubmit(text) {
addOptimisticMessage(text); // 即座に UI 更新
const newMessage = await sendMessage(text); // バックグラウンドで送信
setMessages([...messages, newMessage]); // 実際の結果で更新
}
return (
<>
{optimisticMessages.map(msg => (
<p key={msg.id || msg.text}>
{msg.text}
{msg.sending && <small> (Sending...)</small>}
</p>
))}
</>
);
}
1.3 useOptimistic と他のフックの比較
| フック | 目的 | 更新タイミング |
|---|---|---|
useState |
通常の state 管理 | 即座に同期的に更新 |
useTransition |
低優先度の更新をマーク | バックグラウンドで遅延実行 |
useDeferredValue |
値の遅延表示 | 他の更新が優先される |
useOptimistic |
楽観的 UI 更新 | 即座に仮値を表示、後で実値に置換 |
useOptimistic は useTransition と組み合わせて使うことが多く、トランジション中に楽観的な state を表示するパターンが一般的です。
1.4 useOptimistic の API
const [optimisticState, addOptimistic] = useOptimistic(state, updateFn);
| 引数 | 説明 |
|---|---|
state |
初期状態、または実行中のアクションがない場合に返される値 |
updateFn |
(currentState, optimisticValue) => newState 形式のリデューサ関数 |
| 返り値 | 説明 |
|---|---|
optimisticState |
楽観的な state。アクション中は updateFn の結果、それ以外は state
|
addOptimistic |
楽観的更新をディスパッチする関数 |
💡 「楽観的」という名前の由来
実際にはサーバー処理が完了していないにもかかわらず、「成功するだろう」と楽観的に仮定して UI を先に更新することから、この名前が付けられました。
2. useOptimistic の内部構造を徹底解剖
2.0 全体像: useOptimistic が動く仕組み
🎣 useOptimistic(フック呼び出し)
↓
📝 Optimistic Hook オブジェクトを作成
↓
🔄 addOptimistic 呼び出し
↓
⚡ dispatchOptimisticSetState
↓
🎯 SyncLane で即座にレンダー
↓
✅ トランジション完了時に revertLane で元に戻す
重要なポイント:useOptimistic は SyncLane(同期レーン)で即座に更新を行い、トランジション完了後に自動的に元の値に戻します!
2.1 エントリポイント: packages/react/src/ReactHooks.js
// packages/react/src/ReactHooks.js
export function useOptimistic<S, A>(
passthrough: S,
reducer: ?(S, A) => S,
): [S, (A) => void] {
const dispatcher = resolveDispatcher();
return dispatcher.useOptimistic(passthrough, reducer);
}
他のフックと同様に、resolveDispatcher() を経由して実際の実装に委譲されます。
2.2 マウント時の処理: mountOptimistic
// packages/react-reconciler/src/ReactFiberHooks.js
function mountOptimistic<S, A>(
passthrough: S,
reducer: ?(S, A) => S,
): [S, (A) => void] {
const hook = mountWorkInProgressHook();
hook.memoizedState = hook.baseState = passthrough;
const queue: UpdateQueue<S, A> = {
pending: null,
lanes: NoLanes,
dispatch: null,
// Optimistic state は eager update 最適化を使用しない
lastRenderedReducer: null,
lastRenderedState: null,
};
hook.queue = queue;
// 通常の setState とは異なる dispatch 関数をバインド
const dispatch: A => void = (dispatchOptimisticSetState.bind(
null,
currentlyRenderingFiber,
true, // throwIfDuringRender フラグ
queue,
): any);
queue.dispatch = dispatch;
return [passthrough, dispatch];
}
通常の useState との違い
| 項目 | useState | useOptimistic |
|---|---|---|
| dispatch 関数 | dispatchSetState |
dispatchOptimisticSetState |
| eager update | あり(可能な場合) | なし |
| 更新レーン | 通常のレーン | SyncLane + revertLane |
2.3 Optimistic Update のコア: dispatchOptimisticSetState
// packages/react-reconciler/src/ReactFiberHooks.js
function dispatchOptimisticSetState<S, A>(
fiber: Fiber,
throwIfDuringRender: boolean,
queue: UpdateQueue<S, A>,
action: A,
): void {
const transition = requestCurrentTransition();
// 開発モードでの警告チェック
if (__DEV__) {
if (transition === null) {
// startTransition の外で呼び出された場合
if (peekEntangledActionLane() !== NoLane) {
// 保留中の async action がある場合は OK
} else {
// 通常のイベントハンドラから呼び出された場合は警告
console.error(
'An optimistic state update occurred outside a transition or ' +
'action. To fix, move the update to an action, or wrap ' +
'with startTransition.',
);
}
}
}
// 楽観的更新は SyncLane で即座にコミットされる
const lane = SyncLane;
const update: Update<S, A> = {
lane: lane,
// トランジション完了後に戻すためのレーン
revertLane: requestTransitionLane(transition),
gesture: null,
action,
hasEagerState: false,
eagerState: null,
next: (null: any),
};
// レンダーフェーズ中の更新は禁止
if (isRenderPhaseUpdate(fiber)) {
if (throwIfDuringRender) {
throw new Error('Cannot update optimistic state while rendering.');
}
} else {
const root = enqueueConcurrentHookUpdate(fiber, queue, update, lane);
if (root !== null) {
scheduleUpdateOnFiber(root, fiber, lane);
}
}
}
Update オブジェクトの構造
const update = {
lane: SyncLane, // 即座に適用されるレーン
revertLane: TransitionLane, // 元に戻すためのレーン
action: optimisticValue, // 楽観的更新の値
// ...
};
2.4 更新時の処理: updateOptimistic
// packages/react-reconciler/src/ReactFiberHooks.js
function updateOptimistic<S, A>(
passthrough: S,
reducer: ?(S, A) => S,
): [S, (A) => void] {
const hook = updateWorkInProgressHook();
return updateOptimisticImpl(
hook,
((currentHook: any): Hook),
passthrough,
reducer,
);
}
function updateOptimisticImpl<S, A>(
hook: Hook,
current: Hook | null,
passthrough: S,
reducer: ?(S, A) => S,
): [S, (A) => void] {
// 楽観的更新は常に最新の passthrough 値の上にリベースされる
// passthrough と呼ばれるのは、保留中の更新がなければ
// そのまま返されるから
// ベース state を passthrough にリセット
hook.baseState = passthrough;
// リデューサが提供されていない場合は useState と同じものを使用
const resolvedReducer: (S, A) => S =
typeof reducer === 'function' ? reducer : (basicStateReducer: any);
return updateReducerImpl(hook, ((currentHook: any): Hook), resolvedReducer);
}
💡 passthrough の意味
useOptimistic の最初の引数は「passthrough」と呼ばれます。これは、保留中の楽観的更新がなければ、この値がそのまま「通過して」返されるためです。楽観的更新がある場合は、この値の上にリベースされた結果が返されます。
2.5 再レンダー時の処理: rerenderOptimistic
// packages/react-reconciler/src/ReactFiberHooks.js
function rerenderOptimistic<S, A>(
passthrough: S,
reducer: ?(S, A) => S,
): [S, (A) => void] {
// useState と異なり、useOptimistic は render phase update をサポートしない
// また useState と異なり、passthrough 値が変更された場合に
// すべての保留中更新を再適用する必要がある
const hook = updateWorkInProgressHook();
if (currentHook !== null) {
// 更新の場合。update queue を処理する
return updateOptimisticImpl(
hook,
((currentHook: any): Hook),
passthrough,
reducer,
);
}
// マウントの場合。処理すべき更新はない
hook.baseState = passthrough;
const dispatch = hook.queue.dispatch;
return [passthrough, dispatch];
}
2.6 楽観的更新のライフサイクル
2.7 リベース(Rebase)の仕組み
useOptimistic の重要な特徴は、passthrough 値が変更された場合でも、保留中の楽観的更新が正しく機能することです:
function App({ cart }) {
const [optimisticCartSize, addToOptimisticCart] = useOptimistic(
cart.length,
(prevSize, newItem) => prevSize + 1
);
// cart が外部から更新されても、楽観的更新は正しくリベースされる
}
初期状態: cart = ['A'], optimisticCartSize = 1
1. addToOptimisticCart('B') → optimisticCartSize = 2
(まだサーバーに送信中)
2. 外部から cart = ['A', 'C'] に更新
→ passthrough = 2
→ リベース後: optimisticCartSize = 3 (2 + 1)
3. 'B' の送信完了
→ cart = ['A', 'C', 'B']
→ optimisticCartSize = 3(実際の値と一致)
3. ユースケース
3.1 メッセージ送信フォーム
最も一般的なユースケースは、メッセージの送信です:
function MessageThread({ messages, sendMessage }) {
const formRef = useRef();
const [optimisticMessages, addOptimisticMessage] = useOptimistic(
messages,
(state, newMessage) => [
...state,
{ id: crypto.randomUUID(), text: newMessage, sending: true }
]
);
async function formAction(formData) {
const text = formData.get('message');
addOptimisticMessage(text);
formRef.current.reset();
startTransition(async () => {
await sendMessage(text);
});
}
return (
<>
<form action={formAction} ref={formRef}>
<input name="message" />
<button type="submit">送信</button>
</form>
{optimisticMessages.map(msg => (
<div key={msg.id}>
{msg.text}
{msg.sending && <span>送信中...</span>}
</div>
))}
</>
);
}
3.2 いいねボタン
function LikeButton({ liked, onLike }) {
const [optimisticLiked, setOptimisticLiked] = useOptimistic(liked);
async function handleClick() {
startTransition(async () => {
setOptimisticLiked(!liked);
await onLike(!liked);
});
}
return (
<button onClick={handleClick}>
{optimisticLiked ? '❤️' : '🤍'}
</button>
);
}
3.3 ショッピングカート
function Cart({ items, addItem }) {
const [optimisticItems, addOptimisticItem] = useOptimistic(
items,
(currentItems, newItem) => [...currentItems, { ...newItem, pending: true }]
);
async function handleAddItem(item) {
startTransition(async () => {
addOptimisticItem(item);
await addItem(item);
});
}
return (
<ul>
{optimisticItems.map(item => (
<li key={item.id} style={{ opacity: item.pending ? 0.5 : 1 }}>
{item.name}
</li>
))}
</ul>
);
}
3.4 保留状態のインジケータ
function SubmitButton({ isPending }) {
const [optimisticPending, setOptimisticPending] = useOptimistic(false);
async function handleSubmit() {
startTransition(async () => {
setOptimisticPending(true);
await submitForm();
// トランジション完了後、自動的に false に戻る
});
}
return (
<button onClick={handleSubmit} disabled={optimisticPending}>
{optimisticPending ? '送信中...' : '送信'}
</button>
);
}
4. 注意点とベストプラクティス
4.1 startTransition と組み合わせる
useOptimistic は startTransition 内で使用する必要があります:
// ❌ Bad: startTransition なしで使用
function handleClick() {
addOptimisticMessage(text); // 警告が出る
sendMessage(text);
}
// ✅ Good: startTransition 内で使用
function handleClick() {
startTransition(async () => {
addOptimisticMessage(text);
await sendMessage(text);
});
}
4.2 エラーハンドリング
楽観的更新は成功を前提としているため、エラー時の処理を考慮する必要があります:
function OptimisticForm({ messages, sendMessage }) {
const [error, setError] = useState(null);
const [optimisticMessages, addOptimisticMessage] = useOptimistic(
messages,
(state, newMessage) => [...state, { text: newMessage, sending: true }]
);
async function handleSubmit(text) {
setError(null);
startTransition(async () => {
addOptimisticMessage(text);
try {
await sendMessage(text);
} catch (e) {
setError(e.message);
// トランジション完了で自動的に元に戻る
}
});
}
return (
<>
{error && <div className="error">{error}</div>}
{optimisticMessages.map(msg => (
<div key={msg.id}>{msg.text}</div>
))}
</>
);
}
4.3 レンダーフェーズでの更新は禁止
useOptimistic の更新関数はレンダーフェーズ中に呼び出すことはできません:
// ❌ Bad: レンダー中に呼び出し
function Component() {
const [optimistic, setOptimistic] = useOptimistic(value);
if (someCondition) {
setOptimistic(newValue); // エラー!
}
return <div>{optimistic}</div>;
}
// ✅ Good: イベントハンドラやエフェクト内で呼び出し
function Component() {
const [optimistic, setOptimistic] = useOptimistic(value);
function handleClick() {
startTransition(() => {
setOptimistic(newValue);
});
}
return <button onClick={handleClick}>{optimistic}</button>;
}
5. まとめ
useOptimistic は、React 19 で追加されたフックで、ユーザー体験を大幅に向上させることができる:
| 特徴 | 説明 |
|---|---|
| 即時フィードバック | ネットワーク遅延を感じさせない UI |
| 自動リバート | トランジション完了時に自動的に実際の値に戻る |
| リベース対応 | 外部からの更新があっても正しく動作 |
| 型安全 | TypeScript との相性が良い |
いつ使うべきか
- ✅ フォーム送信後の即時フィードバック
- ✅ いいね/お気に入りなどのトグル操作
- ✅ リストへの追加/削除操作
- ✅ 保留状態のインジケータ表示
いつ使わないべきか
- ❌ 単純な loading state(
useTransitionのisPendingで十分) - ❌ サーバーからの応答が必須の場合(例:認証)
- ❌ 楽観的更新が不自然な場合
この記事は Qiita Advent Calendar 2025「React Hooks を Hackしよう!」企画の Part 13 です。