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.tsThe shape
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:
| Part | Job |
|---|---|
mutationKey | From 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:
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,
});
},
});
}setQueryDataputs 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.
| Change | After success |
|---|---|
| Save a single-record section | setQueryData with the response, then form.reset |
| Create, update or delete in a list | invalidateQueries on the resource's all prefix |
| Toggle a published status | invalidateQueries, because the public list changes too |
Last updated on