最終更新:

TanStack Query楽観的更新パターン: リアルタイムUXの実装テクニック


ユーザーがボタンをクリックした瞬間に、サーバーからのレスポンスを待たずにUIを更新する「楽観的更新(Optimistic Updates)」は、モダンなWebアプリケーションには欠かせないUXテクニックです。本記事では、TanStack Queryを使った楽観的更新の実装方法を、実践的なパターンとともに解説します。

楽観的更新とは

従来のフロー

ユーザーがクリック

ローディング表示

サーバーにリクエスト (500ms~2s)

レスポンス受信

UI更新

ユーザーは更新が完了するまで待たされ、操作感が重くなります。

楽観的更新のフロー

ユーザーがクリック

即座にUI更新 (楽観的)

バックグラウンドでサーバーにリクエスト

成功 → そのまま
失敗 → 元に戻す(ロールバック)

ユーザーは待たされることなく、瞬時にフィードバックを得られます。

基本的な楽観的更新

1. シンプルな「いいね」機能

// hooks/useLikePost.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';

interface Post {
  id: string;
  title: string;
  likes: number;
  isLiked: boolean;
}

export function useLikePost() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: async (postId: string) => {
      const response = await fetch(`/api/posts/${postId}/like`, {
        method: 'POST',
      });

      if (!response.ok) {
        throw new Error('Failed to like post');
      }

      return response.json();
    },

    // 楽観的更新
    onMutate: async (postId) => {
      // 進行中のクエリをキャンセル
      await queryClient.cancelQueries({ queryKey: ['posts', postId] });

      // 現在のデータを取得(ロールバック用)
      const previousPost = queryClient.getQueryData<Post>(['posts', postId]);

      // 楽観的にUIを更新
      queryClient.setQueryData<Post>(['posts', postId], (old) => {
        if (!old) return old;

        return {
          ...old,
          likes: old.isLiked ? old.likes - 1 : old.likes + 1,
          isLiked: !old.isLiked,
        };
      });

      // ロールバック用のデータを返す
      return { previousPost };
    },

    // エラー時のロールバック
    onError: (err, postId, context) => {
      // 元のデータに戻す
      queryClient.setQueryData(['posts', postId], context?.previousPost);

      // エラー通知
      toast.error('いいねに失敗しました');
    },

    // 成功時の処理(オプション)
    onSuccess: (data, postId) => {
      // サーバーからの正確なデータで更新
      queryClient.setQueryData(['posts', postId], data);
    },

    // 完了時の処理
    onSettled: (data, error, postId) => {
      // リスト全体を再検証
      queryClient.invalidateQueries({ queryKey: ['posts'] });
    },
  });
}

// コンポーネントでの使用
function PostCard({ post }: { post: Post }) {
  const likePost = useLikePost();

  return (
    <div className="post-card">
      <h3>{post.title}</h3>
      <button
        onClick={() => likePost.mutate(post.id)}
        disabled={likePost.isPending}
        className={post.isLiked ? 'liked' : ''}
      >
        {post.isLiked ? '❤️' : '🤍'} {post.likes}
      </button>
    </div>
  );
}

2. リストの楽観的更新

// hooks/useAddTodo.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';

interface Todo {
  id: string;
  text: string;
  completed: boolean;
  createdAt: number;
}

export function useAddTodo() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: async (text: string) => {
      const response = await fetch('/api/todos', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ text }),
      });

      if (!response.ok) {
        throw new Error('Failed to add todo');
      }

      return response.json();
    },

    onMutate: async (text) => {
      await queryClient.cancelQueries({ queryKey: ['todos'] });

      const previousTodos = queryClient.getQueryData<Todo[]>(['todos']);

      // 一時的なIDで楽観的に追加
      const optimisticTodo: Todo = {
        id: `temp-${Date.now()}`,
        text,
        completed: false,
        createdAt: Date.now(),
      };

      queryClient.setQueryData<Todo[]>(['todos'], (old = []) => [
        optimisticTodo,
        ...old,
      ]);

      return { previousTodos };
    },

    onError: (err, text, context) => {
      queryClient.setQueryData(['todos'], context?.previousTodos);
      toast.error('追加に失敗しました');
    },

    onSuccess: (newTodo) => {
      // 一時IDを実際のIDに置き換え
      queryClient.setQueryData<Todo[]>(['todos'], (old = []) =>
        old.map((todo) =>
          todo.id.startsWith('temp-') ? newTodo : todo
        )
      );
    },
  });
}

// 使用例
function TodoForm() {
  const [text, setText] = useState('');
  const addTodo = useAddTodo();

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (!text.trim()) return;

    addTodo.mutate(text, {
      onSuccess: () => setText(''),
    });
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        value={text}
        onChange={(e) => setText(e.target.value)}
        placeholder="新しいタスク"
      />
      <button type="submit">追加</button>
    </form>
  );
}

高度なパターン

1. 複数のクエリを同時に更新

// hooks/useUpdateUserProfile.ts
interface User {
  id: string;
  name: string;
  avatar: string;
  bio: string;
}

interface Post {
  id: string;
  author: User;
  content: string;
}

export function useUpdateUserProfile() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: async (updates: Partial<User>) => {
      const response = await fetch('/api/user/profile', {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(updates),
      });

      if (!response.ok) throw new Error('Failed to update profile');

      return response.json();
    },

    onMutate: async (updates) => {
      // 複数のクエリをキャンセル
      await queryClient.cancelQueries({ queryKey: ['user'] });
      await queryClient.cancelQueries({ queryKey: ['posts'] });

      const previousUser = queryClient.getQueryData<User>(['user']);
      const previousPosts = queryClient.getQueryData<Post[]>(['posts']);

      // ユーザープロフィールを更新
      queryClient.setQueryData<User>(['user'], (old) => {
        if (!old) return old;
        return { ...old, ...updates };
      });

      // ユーザーの投稿すべての著者情報を更新
      queryClient.setQueryData<Post[]>(['posts'], (old = []) =>
        old.map((post) => ({
          ...post,
          author: { ...post.author, ...updates },
        }))
      );

      return { previousUser, previousPosts };
    },

    onError: (err, updates, context) => {
      // すべてロールバック
      if (context?.previousUser) {
        queryClient.setQueryData(['user'], context.previousUser);
      }
      if (context?.previousPosts) {
        queryClient.setQueryData(['posts'], context.previousPosts);
      }

      toast.error('プロフィール更新に失敗しました');
    },

    onSettled: () => {
      // 関連するクエリを再検証
      queryClient.invalidateQueries({ queryKey: ['user'] });
      queryClient.invalidateQueries({ queryKey: ['posts'] });
    },
  });
}

2. 無限スクロールリストの楽観的更新

// hooks/useDeletePost.ts
export function useDeletePost() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: async (postId: string) => {
      await fetch(`/api/posts/${postId}`, { method: 'DELETE' });
    },

    onMutate: async (postId) => {
      await queryClient.cancelQueries({ queryKey: ['posts'] });

      // 無限クエリのデータ構造を取得
      const previousData = queryClient.getQueryData<InfiniteData<Post[]>>(['posts']);

      // すべてのページから該当の投稿を削除
      queryClient.setQueryData<InfiniteData<Post[]>>(['posts'], (old) => {
        if (!old) return old;

        return {
          ...old,
          pages: old.pages.map((page) =>
            page.filter((post) => post.id !== postId)
          ),
        };
      });

      return { previousData };
    },

    onError: (err, postId, context) => {
      if (context?.previousData) {
        queryClient.setQueryData(['posts'], context.previousData);
      }
      toast.error('削除に失敗しました');
    },

    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['posts'] });
    },
  });
}

3. ドラッグ&ドロップの順序変更

// hooks/useReorderTodos.ts
export function useReorderTodos() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: async (newOrder: string[]) => {
      const response = await fetch('/api/todos/reorder', {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ order: newOrder }),
      });

      if (!response.ok) throw new Error('Failed to reorder');

      return response.json();
    },

    onMutate: async (newOrder) => {
      await queryClient.cancelQueries({ queryKey: ['todos'] });

      const previousTodos = queryClient.getQueryData<Todo[]>(['todos']);

      // 新しい順序でTodoを並び替え
      queryClient.setQueryData<Todo[]>(['todos'], (old = []) => {
        const todoMap = new Map(old.map((todo) => [todo.id, todo]));
        return newOrder.map((id) => todoMap.get(id)!).filter(Boolean);
      });

      return { previousTodos };
    },

    onError: (err, newOrder, context) => {
      queryClient.setQueryData(['todos'], context?.previousTodos);
      toast.error('並び替えに失敗しました');
    },
  });
}

// React DnDとの統合例
function TodoList() {
  const { data: todos = [] } = useQuery({ queryKey: ['todos'] });
  const reorderTodos = useReorderTodos();

  const handleDragEnd = (result: DropResult) => {
    if (!result.destination) return;

    const items = Array.from(todos);
    const [removed] = items.splice(result.source.index, 1);
    items.splice(result.destination.index, 0, removed);

    const newOrder = items.map((item) => item.id);
    reorderTodos.mutate(newOrder);
  };

  return (
    <DragDropContext onDragEnd={handleDragEnd}>
      <Droppable droppableId="todos">
        {(provided) => (
          <div {...provided.droppableProps} ref={provided.innerRef}>
            {todos.map((todo, index) => (
              <Draggable key={todo.id} draggableId={todo.id} index={index}>
                {(provided) => (
                  <div
                    ref={provided.innerRef}
                    {...provided.draggableProps}
                    {...provided.dragHandleProps}
                  >
                    {todo.text}
                  </div>
                )}
              </Draggable>
            ))}
            {provided.placeholder}
          </div>
        )}
      </Droppable>
    </DragDropContext>
  );
}

4. リアルタイムコラボレーション

// hooks/useCollaborativeDocument.ts
interface Document {
  id: string;
  title: string;
  content: string;
  version: number;
  lastModified: number;
}

export function useUpdateDocument() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: async ({
      id,
      content,
      version,
    }: {
      id: string;
      content: string;
      version: number;
    }) => {
      const response = await fetch(`/api/documents/${id}`, {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ content, version }),
      });

      if (!response.ok) {
        const error = await response.json();
        throw new Error(error.message);
      }

      return response.json();
    },

    onMutate: async ({ id, content }) => {
      await queryClient.cancelQueries({ queryKey: ['document', id] });

      const previousDoc = queryClient.getQueryData<Document>(['document', id]);

      // 楽観的に更新
      queryClient.setQueryData<Document>(['document', id], (old) => {
        if (!old) return old;

        return {
          ...old,
          content,
          version: old.version + 1,
          lastModified: Date.now(),
        };
      });

      return { previousDoc };
    },

    onError: (err, { id }, context) => {
      // バージョンコンフリクトの場合は特別な処理
      if (err.message.includes('version conflict')) {
        toast.error('他のユーザーが編集しています。最新版を取得します。');
        queryClient.invalidateQueries({ queryKey: ['document', id] });
      } else {
        // 通常のロールバック
        queryClient.setQueryData(['document', id], context?.previousDoc);
        toast.error('保存に失敗しました');
      }
    },

    onSuccess: (data, { id }) => {
      // サーバーからの正確なバージョンで更新
      queryClient.setQueryData(['document', id], data);
    },
  });
}

// デバウンスを使った自動保存
function DocumentEditor({ documentId }: { documentId: string }) {
  const { data: document } = useQuery({
    queryKey: ['document', documentId],
  });
  const updateDocument = useUpdateDocument();

  const [content, setContent] = useState(document?.content || '');

  // デバウンスされた保存
  const debouncedUpdate = useMemo(
    () =>
      debounce((newContent: string) => {
        updateDocument.mutate({
          id: documentId,
          content: newContent,
          version: document?.version || 0,
        });
      }, 1000),
    [documentId, document?.version]
  );

  useEffect(() => {
    if (content !== document?.content) {
      debouncedUpdate(content);
    }
  }, [content, debouncedUpdate]);

  return (
    <div>
      <textarea
        value={content}
        onChange={(e) => setContent(e.target.value)}
        className="w-full h-96 p-4"
      />
      <div className="text-sm text-gray-500">
        {updateDocument.isPending && '保存中...'}
        {updateDocument.isSuccess && '保存しました'}
        {updateDocument.isError && '保存に失敗しました'}
      </div>
    </div>
  );
}

アニメーションとの統合

楽観的更新 + Framer Motion

// components/TodoItem.tsx
import { motion, AnimatePresence } from 'framer-motion';

function TodoItem({ todo }: { todo: Todo }) {
  const deleteTodo = useDeleteTodo();
  const toggleTodo = useToggleTodo();

  return (
    <AnimatePresence>
      <motion.div
        layout
        initial={{ opacity: 0, y: -20 }}
        animate={{ opacity: 1, y: 0 }}
        exit={{ opacity: 0, x: -100 }}
        transition={{ duration: 0.2 }}
        className="todo-item"
      >
        <motion.input
          type="checkbox"
          checked={todo.completed}
          onChange={() => toggleTodo.mutate(todo.id)}
          whileTap={{ scale: 1.2 }}
        />

        <span className={todo.completed ? 'line-through' : ''}>
          {todo.text}
        </span>

        <motion.button
          whileHover={{ scale: 1.1 }}
          whileTap={{ scale: 0.9 }}
          onClick={() => deleteTodo.mutate(todo.id)}
        >
          削除
        </motion.button>
      </motion.div>
    </AnimatePresence>
  );
}

エラーハンドリングのベストプラクティス

1. リトライ戦略

export function useLikePostWithRetry() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: likePost,
    onMutate: /* 楽観的更新 */,

    retry: 3, // 3回までリトライ
    retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),

    onError: (err, postId, context) => {
      // 最終的に失敗した場合のみロールバック
      queryClient.setQueryData(['posts', postId], context?.previousPost);
      toast.error('いいねに失敗しました。後でもう一度お試しください。');
    },
  });
}

2. オフライン対応

import { onlineManager } from '@tanstack/react-query';

export function useOfflineAwareMutation() {
  const queryClient = useQueryClient();
  const isOnline = onlineManager.isOnline();

  return useMutation({
    mutationFn: updateData,

    onMutate: async (newData) => {
      // オフライン時は楽観的更新のみ
      if (!isOnline) {
        toast.info('オフラインです。オンライン復帰時に同期されます。');
      }

      // 楽観的更新
      await queryClient.cancelQueries({ queryKey: ['data'] });
      const previousData = queryClient.getQueryData(['data']);
      queryClient.setQueryData(['data'], newData);

      return { previousData };
    },

    // オンライン復帰時に自動的に再試行
    networkMode: 'offlineFirst',
  });
}

まとめ

TanStack Queryの楽観的更新を活用することで、以下が実現できます:

  • 即座のフィードバック: ユーザーは待たされない
  • スムーズなUX: ローディング状態が目立たない
  • 安全なロールバック: エラー時は自動的に元に戻る
  • 複雑な状態管理: 複数のクエリを一貫性を保ちながら更新
  • リアルタイム感: コラボレーション機能も実現可能

楽観的更新は、モダンなWebアプリケーションに欠かせないテクニックです。ユーザー体験を劇的に向上させるために、ぜひ実装してみてください。