👊

React19のuseOptimisticとDnDを組みせるといい感じ

2025/02/08に公開

React19のuseOptimisticの使いどころがそんなにわからないなと思っていた昨今ですが、めっちゃハマる使いどころを見つけたので共有します!

useOptimisticってなに?

https://ja.react.dev/reference/react/useOptimistic

「楽観的更新をするためのHook」と記載されており、「データの更新→ロード→更新されたあとのデータを表示」の動作のロードの部分を割愛するための仕組みで、確実にデータの更新ができるであろうケースに使用するとアプリを高速で操作できるように見せることができるようです。

useOptimisticの使用の有無の比較

なし

あり

なしのほうがアイテムを離したあとに少しカクついていることがわかります。

DnDでデータを並び替える実装をみる(なし)

ドラックアンドドロップ(以下DnD)で操作を行う場合の多くはDropしたときにDBのデータも書き換えたいものだと思います。今回はリストを並び替えるという動作を例にあげます。

このような実装ではUIに実データをそのまま使用してしまうがちですが、それだとViewで使用しているデータが不整合になる時間が生じてしまい、リアルタイム制が高いDnDの実装だと、先程示したようなカクつきが生まれてしまいます。

DnDでリストを並び替えるコンポーネントをざっと作るとこんな感じです。

※今回はdnd-kitを使用しています。

'use client';

import {
  DndContext,
  type DragEndEvent,
  KeyboardSensor,
  PointerSensor,
  closestCenter,
  useSensor,
  useSensors,
} from '@dnd-kit/core';
import {
  SortableContext,
  arrayMove,
  sortableKeyboardCoordinates,
  verticalListSortingStrategy,
} from '@dnd-kit/sortable';

type Props = {
  items: Item[]; // APIなどで取得した実データ
};
const List = ({ items }: Props) => {
  const sensors = useSensors(
    useSensor(PointerSensor),
    useSensor(KeyboardSensor, {
      coordinateGetter: sortableKeyboardCoordinates,
    }),
  );

  const handleDragEnd = async(event: DragEndEvent) => {
    const { active, over } = event;
    if (over === null) return;

    if (active.id !== over.id) {
      const oldIndex = lists.findIndex((item) => item.id === active.id);
      const newIndex = lists.findIndex((item) => item.id === over.id);
      await setItems(arrayMove(items, oldIndex, newIndex)); // 保存する処理(非同期)
    }
  };

  return (
    <DndContext
      sensors={sensors}
      collisionDetection={closestCenter}
      onDragEnd={handleDragEnd}
    >
      <SortableContext
        items={items}
        strategy={verticalListSortingStrategy}
      >
        <div>
          {items.map((item) => (
            <ListItem key={item.id} id={item.id} item={item} />
          ))}
        </div>
      </SortableContext>
    </DndContext>
  );
};

挙動はデータの更新がされるまでは順番がわかっていないので、一瞬ListItemが元いた場所に戻ろうとしてしまいます。

この解決方法はいくらか想像つくと思いますが、React19からは、useOptimisticを使うのが良さそうです。

useOptimistic を使ったDnDの実装をみる(あり)

今回の並び替えの処理は失敗する可能性が低そうなので、useOptimisticを使用して楽観的更新をします。

'use client';

import {
  DndContext,
  type DragEndEvent,
  KeyboardSensor,
  PointerSensor,
  closestCenter,
  useSensor,
  useSensors,
} from '@dnd-kit/core';
import {
  SortableContext,
  arrayMove,
  sortableKeyboardCoordinates,
  verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { startTransition, useOptimistic } from 'react';

type Props = {
  items: Item[]; // APIなどで取得した実データ
};
const List = ({ items }: Props) => {
  const [optimisticItems, setOptimisticItems] = useOptimistic(
    lists, // 実データを渡す
    (_currentItems, newItems: Item[]) => newItems, // 更新関数(今回は前回の値 _currentItems は使用しない)
  );

  const sensors = useSensors(
    useSensor(PointerSensor),
    useSensor(KeyboardSensor, {
      coordinateGetter: sortableKeyboardCoordinates,
    }),
  );

  const handleDragEnd = (event: DragEndEvent) => {
    const { active, over } = event;
    if (over === null) return;

    if (active.id !== over.id) {
      const oldIndex = lists.findIndex((item) => item.id === active.id);
      const newIndex = lists.findIndex((item) => item.id === over.id);

      // ※useOptimistic は startTransition,もしくはserver functionといっしょに使用すること
      startTransition(async () => {
        setOptimisticItems(arrayMove(optimisticLists, oldIndex, newIndex));
        await setItems(arrayMove(lists, oldIndex, newIndex));
      });
    }
  };

  return (
    <DndContext
      sensors={sensors}
      collisionDetection={closestCenter}
      onDragEnd={handleDragEnd}
    >
      <SortableContext items={optimisticItems} strategy={verticalListSortingStrategy}>
        <div>
          {optimisticItems.map((item) => (
            <ListItem key={item.id} id={item.id} item={item} />
          ))}
        </div>
      </SortableContext>
    </DndContext>
  );
};

補足

const [optimisticItems, setOptimisticItems] = useOptimistic(
  lists, // 実データを渡す
  (_currentItems, newItems: Item[]) => newItems, // 更新関数(今回は前回の値 _currentItems は使用しない)
);

ここで実データをuseStateに渡すみたいなことをしています。optimisticItemsはclientで使用するstateです。更新関数は自由に組めるのでuseReducerみたいな感じと捉えるといいでしょう。

startTransition(async () => {
    setOptimisticItems(arrayMove(optimisticLists, oldIndex, newIndex));  // optimisticItemsの更新
    await setItems(arrayMove(lists, oldIndex, newIndex)); // DBの更新
});

そして、useOptimisticの更新関数を、DBを更新する処理と一緒に呼び出してあげましょう。これで、optimisticItemsの更新とDBの更新が行われclientではoptimisticItems をViewに使用しているので動きがいい感じになったというわけです。

また、この更新の処理にはstartTransitionを使用する、更新処理にserver function (action)を使用するようにしましょう。

というわけで、かなりきれいにDnDで並び替えすることができました🎉

ブラウザを更新するとDBの値がちゃんと書き換わっていることを確認できました!

useOptimistic はシンプルなCRUD処理でガンガン使って良さそうだと思いました!

仮にエラーになった場合はToastやスナックバーなどを表示して失敗したことをユーザーに伝えればいいのかなと思います!

Discussion