Связь мутаций с обновлением кеша

optimistic updates — механизм оптимистичного обновления данных, при котором интерфейс изменяется ещё до завершения HTTP-запроса.

Пользователь нажимает кнопку, интерфейс мгновенно показывает результат, а серверный запрос выполняется параллельно. Если запрос завершается успешно — изменения остаются. Если возникает ошибка — состояние откатывается назад.

Подход используется для:

  • списков задач;
  • лайков;
  • комментариев;
  • переключателей;
  • корзины интернет-магазина;
  • чатов;
  • реакций в социальных сетях;
  • обновления таблиц и списков.

Без optimistic updates интерфейс часто выглядит медленным:

  1. пользователь нажимает кнопку;
  2. интерфейс ждёт ответа сервера;
  3. только потом обновляется состояние.

Оптимистичное обновление устраняет эту задержку.


Как работает optimistic update

Последовательность обычно выглядит так:

  1. пользователь инициирует mutation;
  2. TanStack Query временно обновляет cache;
  3. UI моментально перерисовывается;
  4. сервер обрабатывает запрос;
  5. при успехе данные синхронизируются;
  6. при ошибке выполняется rollback.

Базовая структура optimistic update

Основные части:

  • onMutate
  • setQueryData
  • rollback
  • onError
  • invalidateQueries

Пример:

const queryClient = useQueryClient();

const mutation = useMutation({
    mutationFn: updateTodo,

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

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

        queryClient.setQueryData(
            ['todos'],
            (old = []) => {
                return [...old, newTodo];
            }
        );

        return { previousTodos };
    },

    onError: (error, newTodo, context) => {
        queryClient.setQueryData(
            ['todos'],
            context.previousTodos
        );
    },

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

Роль onMutate

onMutate — ключевой элемент optimistic updates.

Именно здесь:

  • выполняется временное изменение cache;
  • сохраняется предыдущее состояние;
  • подготавливается rollback.

Пример:

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

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

    queryClient.setQueryData(
        ['todos'],
        (old = []) => [...old, newTodo]
    );

    return { previousTodos };
}

Почему cancelQueries важен

Во время optimistic update может выполняться параллельный refetch.

Проблема:

  1. optimistic update изменяет cache;
  2. старый запрос завершается позже;
  3. старые данные перезаписывают optimistic state.

cancelQueries предотвращает такую ситуацию.

await queryClient.cancelQueries({
    queryKey: ['todos']
});

Это особенно важно при:

  • медленном интернете;
  • polling;
  • background refetch;
  • focus refetch;
  • reconnect refetch.

Сохранение предыдущего состояния

Rollback невозможен без сохранения старых данных.

Обычно используется:

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

После этого snapshot возвращается через context.

return {
    previousTodos
};

Временное обновление cache

Главный механизм optimistic update:

queryClient.setQueryData(
    ['todos'],
    (old = []) => [...old, newTodo]
);

setQueryData обновляет cache напрямую без запроса к серверу.

Компоненты автоматически получают новые данные.


Rollback при ошибке

Если mutation завершилась ошибкой:

onError: (error, variables, context) => {
    queryClient.setQueryData(
        ['todos'],
        context.previousTodos
    );
}

Интерфейс возвращается к предыдущему состоянию.


Финальная синхронизация через invalidateQueries

Даже после успешного optimistic update рекомендуется делать refetch.

Причины:

  • сервер мог изменить данные;
  • сервер мог добавить поля;
  • сервер мог выполнить нормализацию;
  • backend мог изменить сортировку;
  • могли измениться связанные сущности.
onSettled: () => {
    queryClient.invalidateQueries({
        queryKey: ['todos']
    });
}

Optimistic update для добавления элемента

Добавление задачи

const addTodoMutation = useMutation({
    mutationFn: createTodo,

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

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

        queryClient.setQueryData(
            ['todos'],
            (old = []) => {
                return [
                    ...old,
                    {
                        ...newTodo,
                        id: Date.now(),
                        optimistic: true
                    }
                ];
            }
        );

        return { previousTodos };
    },

    onError: (error, variables, context) => {
        queryClient.setQueryData(
            ['todos'],
            context.previousTodos
        );
    },

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

Временные id

При optimistic create сервер ещё не выдал настоящий id.

Поэтому часто используются временные идентификаторы:

id: Date.now()

или:

id: crypto.randomUUID()

Флаг optimistic

Полезно помечать временные элементы:

optimistic: true

Это позволяет:

  • отображать loader;
  • делать полупрозрачный UI;
  • блокировать повторные действия;
  • визуально отличать временные данные.

Optimistic update для удаления

Удаление элемента

const deleteMutation = useMutation({
    mutationFn: deleteTodo,

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

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

        queryClient.setQueryData(
            ['todos'],
            (old = []) => {
                return old.filter(
                    todo => todo.id !== todoId
                );
            }
        );

        return { previousTodos };
    },

    onError: (error, todoId, context) => {
        queryClient.setQueryData(
            ['todos'],
            context.previousTodos
        );
    },

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

Optimistic update для редактирования

Обновление элемента

const updateMutation = useMutation({
    mutationFn: updateTodo,

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

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

        queryClient.setQueryData(
            ['todos'],
            (old = []) => {
                return old.map(todo => {
                    if (todo.id === updatedTodo.id) {
                        return {
                            ...todo,
                            ...updatedTodo
                        };
                    }

                    return todo;
                });
            }
        );

        return { previousTodos };
    },

    onError: (error, variables, context) => {
        queryClient.setQueryData(
            ['todos'],
            context.previousTodos
        );
    },

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

Оптимистичное переключение boolean-состояния

Toggle-пример

const toggleMutation = useMutation({
    mutationFn: toggleTodo,

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

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

        queryClient.setQueryData(
            ['todos'],
            (old = []) => {
                return old.map(todo => {
                    if (todo.id === todoId) {
                        return {
                            ...todo,
                            completed: !todo.completed
                        };
                    }

                    return todo;
                });
            }
        );

        return { previousTodos };
    },

    onError: (error, variables, context) => {
        queryClient.setQueryData(
            ['todos'],
            context.previousTodos
        );
    }
});

Работа с несколькими query

Обновление нескольких cache одновременно

Иногда одна mutation влияет на несколько query.

Например:

  • список постов;
  • детали поста;
  • статистика автора;
  • счётчики.
queryClient.setQueryData(
    ['posts'],
    updatePosts
);

queryClient.setQueryData(
    ['post', postId],
    updateSinglePost
);

queryClient.setQueryData(
    ['stats'],
    updateStats
);

Optimistic updates и pagination

Проблемы пагинации

Оптимистичные обновления становятся сложнее при:

  • infinite query;
  • cursor pagination;
  • offset pagination;
  • виртуализации;
  • больших списках.

Например:

['posts', page]

Mutation может затрагивать:

  • текущую страницу;
  • предыдущие страницы;
  • следующую страницу;
  • общий счётчик.

Optimistic update в infinite query

Структура infinite query:

{
    pages: [],
    pageParams: []
}

Обновление:

queryClient.setQueryData(
    ['feed'],
    (oldData) => {
        return {
            ...oldData,
            pages: oldData.pages.map(page => {
                return {
                    ...page,
                    items: page.items.map(item => {
                        if (item.id === updated.id) {
                            return updated;
                        }

                        return item;
                    })
                };
            })
        };
    }
);

Проблемы optimistic updates

Возможная рассинхронизация

Оптимистичный UI может временно отличаться от сервера.

Причины:

  • серверная валидация;
  • backend-правила;
  • нормализация данных;
  • permissions;
  • race conditions.

Конфликты параллельных mutation

Проблема:

  1. mutation A меняет данные;
  2. mutation B меняет те же данные;
  3. сервер отвечает в другом порядке.

Это может приводить к повреждению состояния.


Дублирование логики backend

Иногда frontend начинает повторять backend-логику:

  • сортировки;
  • вычисления;
  • фильтрацию;
  • правила доступа;
  • derived state.

Чем сложнее backend-логика — тем опаснее optimistic updates.


Когда optimistic updates подходят лучше всего

Наиболее безопасные сценарии:

  • лайки;
  • toggles;
  • небольшие изменения;
  • локальные правки;
  • быстрые CRUD-операции;
  • чаты;
  • комментарии;
  • todo list.

Когда optimistic updates нежелательны

Плохие сценарии:

  • банковские операции;
  • денежные переводы;
  • сложные транзакции;
  • критически важные данные;
  • операции с высокой вероятностью ошибок;
  • тяжёлая backend-валидация.

Отличие optimistic updates от invalidateQueries

invalidateQueries

Подход:

  1. mutation;
  2. ожидание ответа сервера;
  3. refetch;
  4. обновление UI.

Плюсы:

  • простота;
  • надёжность;
  • сервер — единственный источник истины.

Минусы:

  • интерфейс ощущается медленным.

Optimistic updates

Подход:

  1. мгновенное изменение UI;
  2. фоновый запрос;
  3. rollback при ошибке.

Плюсы:

  • быстрый UX;
  • ощущение мгновенного отклика;
  • плавность интерфейса.

Минусы:

  • сложность;
  • rollback;
  • конфликты;
  • рассинхронизация.

Комбинирование optimistic updates и server response

Иногда сервер возвращает обновлённую сущность.

Тогда можно сразу синхронизировать cache:

onSuccess: (serverTodo) => {
    queryClient.setQueryData(
        ['todo', serverTodo.id],
        serverTodo
    );
}

Optimistic updates без rollback

Иногда rollback не нужен.

Например:

  • аналитика;
  • просмотры;
  • неважные действия;
  • временные реакции.
onMutate: () => {
    queryClient.setQueryData(...);
}

Без:

onError

Использование mutation variables

variables содержат аргументы mutation.

onMutate: (variables) => {
    console.log(variables);
}

Пример:

mutation.mutate({
    id: 1,
    title: 'New title'
});

Использование context

context — объект, возвращённый из onMutate.

return {
    previousTodos
};

Доступ:

onError: (
    error,
    variables,
    context
) => {
    console.log(context.previousTodos);
}

Архитектура optimistic updates

Крупные проекты часто выносят optimistic-логику отдельно.

Пример:

function optimisticUpdateTodo(
    queryClient,
    updatedTodo
) {
    queryClient.setQueryData(
        ['todos'],
        (old = []) => {
            return old.map(todo => {
                if (todo.id === updatedTodo.id) {
                    return {
                        ...todo,
                        ...updatedTodo
                    };
                }

                return todo;
            });
        }
    );
}

Optimistic updates и TypeScript

Типизация context:

type TodosContext = {
    previousTodos: Todo[];
};

Использование:

useMutation<
    Todo,
    Error,
    UpdateTodoInput,
    TodosContext
>({
    mutationFn: updateTodo
});

Optimistic updates и offline-first

TanStack Query поддерживает offline-сценарии.

Optimistic updates особенно полезны при:

  • мобильном интернете;
  • нестабильной сети;
  • PWA;
  • offline-first приложениях.

Интерфейс продолжает реагировать даже без мгновенного ответа сервера.


Практические рекомендации

Не делать optimistic update слишком сложным

Если требуется:

  • пересчитать десятки query;
  • синхронизировать множество сущностей;
  • воспроизвести backend-логику;

то обычный refetch часто надёжнее.


Минимизировать область optimistic update

Лучше обновлять:

  • один объект;
  • один список;
  • небольшой fragment cache.

Чем меньше область изменений — тем ниже риск ошибок.


Всегда сохранять snapshot

Даже если кажется, что rollback не понадобится.

const previous =
    queryClient.getQueryData(queryKey);

Использовать invalidateQueries после mutation

Даже при успешном optimistic update.

queryClient.invalidateQueries({
    queryKey: ['todos']
});

Следить за race conditions

Особенно при:

  • быстрых повторных кликах;
  • debounce;
  • autosave;
  • realtime UI;
  • нескольких вкладках браузера.

Полный production-пример

const queryClient = useQueryClient();

const updateTodoMutation = useMutation({
    mutationFn: async (todo) => {
        const response = await api.patch(
            `/todos/${todo.id}`,
            todo
        );

        return response.data;
    },

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

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

        queryClient.setQueryData(
            ['todos'],
            (old = []) => {
                return old.map(todo => {
                    if (todo.id === updatedTodo.id) {
                        return {
                            ...todo,
                            ...updatedTodo,
                            optimistic: true
                        };
                    }

                    return todo;
                });
            }
        );

        return {
            previousTodos
        };
    },

    onError: (
        error,
        updatedTodo,
        context
    ) => {
        queryClient.setQueryData(
            ['todos'],
            context.previousTodos
        );
    },

    onSuccess: (serverTodo) => {
        queryClient.setQueryData(
            ['todos'],
            (old = []) => {
                return old.map(todo => {
                    if (todo.id === serverTodo.id) {
                        return {
                            ...serverTodo,
                            optimistic: false
                        };
                    }

                    return todo;
                });
            }
        );
    },

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