Workbench

Mutations

One hook shape for every CMS save, server messages in toasts, field errors on the form, and setQueryData for single-record sections.

From: the CMS dashboard behind the company website, which manages every section of the site. It has 231 mutation hooks; I wrote 217 of them. At that scale, the important thing is that every hook looks the same.

One hook per action

Every create, update, delete and toggle gets its own hook in features/<name>/hooks/, named for what it does:

use-create-blog-mutation.ts
use-update-blog-mutation.ts
use-delete-blog-mutation.ts
use-toggle-blog-status-mutation.ts

The shape

features/dashboard/blogs/hooks/use-update-blog-mutation.ts
interface UseUpdateBlogMutationOptions {
  form?: UseFormReturn<BlogFormValues>;
  onSuccess?: () => void;
}

export function useUpdateBlogMutation({ form, onSuccess }: UseUpdateBlogMutationOptions = {}) {
  const queryClient = useQueryClient();
  const blogs = BLOGS_ADMIN_COPY;

  return useMutation({
    mutationKey: MUTATION_KEYS.cmsBlogs.update,
    mutationFn: ({ id, data }: UpdateCmsBlogPayload) => updateCmsBlog(id, data),
    onSuccess: (response) => {
      toast.success(response.message || blogs.mutations.update.success);
      queryClient.invalidateQueries({ queryKey: QUERY_KEYS.cmsBlogs.all() });
      onSuccess?.();
    },
    onError: (error) => {
      handleApiError({
        error,
        form,
        fallbackMessage: blogs.mutations.update.error,
      });
    },
  });
}

Each part has one job:

PartJob
mutationKeyFrom MUTATION_KEYS, never inline, so mutations can be found and tracked
toast.success(response.message || …)Show the server's message, with local copy as the fallback
invalidateQueries({ queryKey: ….all() })Refresh every list and detail for this resource through the shared prefix
onSuccess?.()Let the caller close a dialog or navigate, without the hook knowing about it
handleApiError({ error, form })Put server validation errors on the matching form fields

Let the server write the success message

The first version of the hooks always showed a local string, like "FAQ created". In one change I updated the API functions to return the server's response, and 22 hooks across blogs, courses, FAQs, gallery, products, SEO, users and site settings to prefer its message:

- onSuccess: () => {
-   toast.success(faq.mutations.create.success);
+ onSuccess: (response) => {
+   toast.success(response.message || faq.mutations.create.success);

The backend knows what actually happened, so its message can be more precise than a fixed string, and wording can change without a frontend release. The local copy is still there for when the response has no message.

Server errors on the right field

Passing form into the hook lets the project's shared handleApiError map the API's field errors onto React Hook Form with setError, then focus and scroll to the first invalid field. A validation error on, for example, the slug appears under the slug input, not only in a toast. Without a form, it falls back to a toast with fallbackMessage.

Single-record sections: write the response into the cache

Most of the CMS manages lists (blogs, FAQs, gallery). But many website sections are a single record, edited with one form: Our Story, Impact Made, Our Journey, Insights, Privacy Policy, Terms of Service, and more.

For those, invalidating would throw away data the server has just sent back. Instead, the save hook writes the response straight into the cache and resets the form to it:

features/dashboard/our-story/hooks/use-update-our-story-mutation.ts
export function useUpdateOurStoryMutation({ form }: UseUpdateOurStoryMutationOptions = {}) {
  const queryClient = useQueryClient();
  const ourStoryAdmin = OUR_STORY_ADMIN_COPY;

  return useMutation<UpdateOurStoryResponse, Error, OurStoryFormValues>({
    mutationKey: MUTATION_KEYS.cms.updateOurStory,
    mutationFn: (values) => updateOurStory(mapOurStoryFormToPayload(values)),
    onSuccess: (response) => {
      queryClient.setQueryData(QUERY_KEYS.ourStory, response.data); 
      toast.success(response.message || ourStoryAdmin.mutations.update.success);
      form?.reset(mapOurStoryToFormValues(response.data)); 
    },
    onError: (error) => {
      handleApiError({
        error,
        form,
        fallbackMessage: ourStoryAdmin.mutations.update.error,
      });
    },
  });
}
  • setQueryData puts the saved record in the cache, so there's no extra request and no flash of old data.
  • form.reset(response.data) makes the saved values the form's new defaults. The form is no longer "dirty", so the save button and any unsaved-changes warning behave correctly. If the server changed a value (a trimmed string, a generated slug), the form shows the server's version.

21 single-record sections use this pattern.

Which one to use

The rule

If the response contains the whole new state of the query, write it with setQueryData. If the change affects queries the response doesn't describe (a list's order, counts, pagination), invalidate.

ChangeAfter success
Save a single-record sectionsetQueryData with the response, then form.reset
Create, update or delete in a listinvalidateQueries on the resource's all prefix
Toggle a published statusinvalidateQueries, because the public list changes too

Last updated on

On this page