0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

React Hooks を Hackしよう!【Part15: useOptimisticをふかぼってみよう】

Last updated at Posted at 2025-12-15

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 更新 即座に仮値を表示、後で実値に置換

useOptimisticuseTransition と組み合わせて使うことが多く、トランジション中に楽観的な 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 と組み合わせる

useOptimisticstartTransition 内で使用する必要があります:

// ❌ 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(useTransitionisPending で十分)
  • ❌ サーバーからの応答が必須の場合(例:認証)
  • ❌ 楽観的更新が不自然な場合

この記事は Qiita Advent Calendar 2025「React Hooks を Hackしよう!」企画の Part 13 です。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?