Обработка успешных мутаций

Мутации в TanStack Query предназначены для изменения данных на сервере: создания, обновления, удаления записей, отправки форм, изменения состояния объектов и выполнения любых операций записи.

После успешного завершения мутации приложение обычно должно:

  • обновить кэш;
  • синхронизировать интерфейс;
  • показать уведомление;
  • выполнить переход;
  • очистить форму;
  • перезапросить связанные данные;
  • изменить локальное состояние.

Для этого в TanStack Query используется обработчик onSuccess.

Базовый пример:

const mutation = useMutation({
    mutationFn: createPost,

    onSuccess: (data) => {
        console.log('Мутация выполнена успешно');
        console.log(data);
    }
});

Параметр data содержит результат, возвращённый сервером.

Например:

async function createPost(post) {
    const response = await fetch('/api/posts', {
        method: 'POST',
        body: JSON.stringify(post)
    });

    return response.json();
}

Если сервер возвращает:

{
    "id": 15,
    "title": "Новая статья"
}

то data внутри onSuccess будет содержать этот объект.


Структура обработчика onSuccess

Обработчик получает несколько аргументов:

onSuccess: (data, variables, context) => {

}

data

Результат успешной мутации.

onSuccess: (data) => {
    console.log(data.id);
}

variables

Аргументы, переданные в mutate.

mutation.mutate({
    title: 'React'
});
onSuccess: (data, variables) => {
    console.log(variables.title);
}

context

Контекст, возвращённый из onMutate.

Используется при optimistic update.

onMutate: async (newTodo) => {
    return {
        previousTodos: queryClient.getQueryData(['todos'])
    };
}
onSuccess: (data, variables, context) => {
    console.log(context.previousTodos);
}

Обновление кэша после успешной мутации

Одна из главных задач onSuccess — синхронизация кэша.

Инвалидация запросов

Самый распространённый подход — пометить запросы устаревшими.

const queryClient = useQueryClient();

const mutation = useMutation({
    mutationFn: createPost,

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

После инвалидации TanStack Query автоматически выполнит повторный запрос.


Почему invalidateQueries считается стандартным подходом

Инвалидация:

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

Особенно полезна, когда:

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

Прямое обновление кэша через setQueryData

Иногда повторный запрос не нужен.

Если сервер уже вернул актуальные данные, можно обновить кэш вручную.

onSuccess: (newPost) => {
    queryClient.setQueryData(
        ['posts'],
        (oldPosts = []) => {
            return [...oldPosts, newPost];
        }
    );
}

Преимущества setQueryData

Мгновенное обновление интерфейса

Повторный HTTP-запрос отсутствует.


Снижение нагрузки

Не происходит дополнительного обращения к серверу.


Более плавный UX

Интерфейс обновляется сразу после ответа сервера.


Недостатки ручного обновления

Ручное обновление сложнее поддерживать.

Проблемы возникают, если:

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

Поэтому invalidateQueries используется чаще.


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

Пример изменения одной записи:

onSuccess: (updatedPost) => {
    queryClient.setQueryData(
        ['posts'],
        (oldPosts = []) => {
            return oldPosts.map(post =>
                post.id === updatedPost.id
                    ? updatedPost
                    : post
            );
        }
    );
}

Обновление детального запроса

Часто существует отдельный запрос детали сущности.

Например:

useQuery({
    queryKey: ['post', postId],
    queryFn: fetchPost
});

После успешного обновления записи можно синхронизировать этот кэш:

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

Одновременное обновление нескольких кэшей

В реальных приложениях данные часто дублируются.

Например:

  • список статей;
  • детальная страница;
  • избранное;
  • последние публикации;
  • профиль автора.

После успешной мутации иногда необходимо обновить несколько запросов.

onSuccess: (updatedPost) => {
    queryClient.setQueryData(
        ['post', updatedPost.id],
        updatedPost
    );

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

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

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

onSuccess поддерживает асинхронный код.

onSuccess: async () => {
    await queryClient.invalidateQueries({
        queryKey: ['posts']
    });

    console.log('Кэш обновлён');
}

TanStack Query дождётся завершения Promise.


Последовательные действия после мутации

Иногда операции должны выполняться строго по порядку.

onSuccess: async (data) => {
    await queryClient.invalidateQueries({
        queryKey: ['posts']
    });

    navigate(`/posts/${data.id}`);
}

В этом примере переход произойдёт только после обновления данных.


Показ уведомлений

Очень распространённый сценарий.

onSuccess: () => {
    toast.success('Статья сохранена');
}

Очистка формы

После успешной отправки форма часто сбрасывается.

onSuccess: () => {
    reset();
}

Закрытие модальных окон

onSuccess: () => {
    setIsModalOpen(false);
}

Перенаправление пользователя

onSuccess: (data) => {
    navigate(`/posts/${data.id}`);
}

Комбинирование нескольких действий

Обычно onSuccess содержит сразу несколько операций.

onSuccess: async (data) => {
    queryClient.invalidateQueries({
        queryKey: ['posts']
    });

    toast.success('Пост создан');

    reset();

    navigate(`/posts/${data.id}`);
}

Локальные обработчики mutate

Обработчики можно передавать непосредственно в mutate.

mutation.mutate(
    values,
    {
        onSuccess: () => {
            console.log('Успех');
        }
    }
);

Разница между глобальным и локальным onSuccess

Глобальный обработчик

Определяется внутри useMutation.

useMutation({
    mutationFn,
    onSuccess: () => {}
});

Подходит для:

  • общей логики;
  • обновления кэша;
  • инвалидации запросов.

Локальный обработчик

Передаётся в mutate.

mutate(data, {
    onSuccess: () => {}
});

Подходит для:

  • UI-логики;
  • уведомлений;
  • локальных эффектов;
  • действий конкретного экрана.

Порядок вызова обработчиков

Сначала вызывается глобальный onSuccess, затем локальный.

const mutation = useMutation({
    mutationFn,
    onSuccess: () => {
        console.log('global');
    }
});

mutation.mutate(data, {
    onSuccess: () => {
        console.log('local');
    }
});

Результат:

global
local

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

mutateAsync позволяет работать через async/await.

const mutation = useMutation({
    mutationFn: createPost
});
const handleSubmit = async () => {
    try {
        const data = await mutation.mutateAsync(values);

        console.log(data);
    } catch (error) {
        console.log(error);
    }
};

Когда mutateAsync удобнее onSuccess

mutateAsync полезен:

  • при сложных последовательностях;
  • при интеграции с form libraries;
  • при необходимости линейного async-кода;
  • при orchestration нескольких мутаций.

Совместное использование mutateAsync и onSuccess

Оба механизма могут использоваться одновременно.

const mutation = useMutation({
    mutationFn: createPost,

    onSuccess: () => {
        console.log('Глобальная логика');
    }
});
await mutation.mutateAsync(values);

Типичная архитектура успешных мутаций

Во многих проектах применяется следующая схема:

В onSuccess

  • обновление кэша;
  • invalidateQueries;
  • синхронизация данных.

В компоненте

  • уведомления;
  • закрытие модалки;
  • redirect;
  • reset формы.

Избежание дублирования логики

Плохой пример:

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

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

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

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

Большое количество ручных инвалидаций усложняет поддержку.


Централизация логики

Полезно выносить обновление кэша в отдельные функции.

function invalidatePostQueries(queryClient) {
    queryClient.invalidateQueries({
        queryKey: ['posts']
    });

    queryClient.invalidateQueries({
        queryKey: ['post-stats']
    });
}
onSuccess: () => {
    invalidatePostQueries(queryClient);
}

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

Распространённый подход:

function useCreatePost() {
    const queryClient = useQueryClient();

    return useMutation({
        mutationFn: createPost,

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

Компонент получает готовую бизнес-логику.


Обработка успешного удаления

После удаления запись обычно убирается из кэша.

onSuccess: (_, deletedId) => {
    queryClient.setQueryData(
        ['posts'],
        (oldPosts = []) => {
            return oldPosts.filter(
                post => post.id !== deletedId
            );
        }
    );
}

Обработка успешного создания

onSuccess: (newPost) => {
    queryClient.setQueryData(
        ['posts'],
        (oldPosts = []) => {
            return [newPost, ...oldPosts];
        }
    );
}

Обработка успешного обновления

onSuccess: (updatedPost) => {
    queryClient.setQueryData(
        ['posts'],
        (oldPosts = []) => {
            return oldPosts.map(post =>
                post.id === updatedPost.id
                    ? updatedPost
                    : post
            );
        }
    );
}

Влияние staleTime

Если запрос имеет большой staleTime, то без invalidateQueries интерфейс может продолжать показывать устаревшие данные.

useQuery({
    queryKey: ['posts'],
    queryFn: fetchPosts,
    staleTime: 1000 * 60 * 10
});

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


Работа с серверными идентификаторами

При создании сущности сервер часто генерирует id.

onSuccess: (createdUser) => {
    console.log(createdUser.id);
}

Это важно для:

  • redirect;
  • обновления списка;
  • открытия страницы детали;
  • построения queryKey.

Ошибки при обработке успешных мутаций

Избыточные invalidateQueries

Частые глобальные инвалидации могут приводить к лишним запросам.


Несогласованность кэша

Ручное обновление нескольких запросов легко приводит к ошибкам.


Смешивание бизнес-логики и UI

Плохой пример:

onSuccess: () => {
    toast.success('Успех');

    openModal();

    setSidebarState();

    updateTheme();

    playAnimation();
}

Мутация становится слишком зависимой от конкретного интерфейса.


Рекомендуемый подход

Серверная синхронизация

Внутри mutation hooks:

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

UI-эффекты

На уровне компонентов:

mutate(data, {
    onSuccess: () => {
        toast.success('Сохранено');
    }
});

Взаимодействие с optimistic updates

При optimistic update успешная мутация подтверждает временные изменения.

onMutate: async (newPost) => {
    const previousPosts =
        queryClient.getQueryData(['posts']);

    queryClient.setQueryData(
        ['posts'],
        (old = []) => [...old, newPost]
    );

    return { previousPosts };
}

После успеха:

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

Стабильность интерфейса после успешной мутации

Грамотная обработка успешных мутаций обеспечивает:

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