Apr 2026

·

4 min read

A simple rule for keeping mutations consistent across the codebase

Every time I wrote mutation logic inline, something ended up missing. Moving it all into the hook fixed that, and adding the rule to CLAUDE.md meant Claude stopped getting it wrong too.

Writing a mutation is mostly muscle memory at this point. After you have done it a hundred times, you stop thinking about it. You write the hook, wire up the component, and move on. Then two days later someone reports that the success toast never showed, or that the list did not refresh after deleting an item, or that the modal stayed open after saving. Small bugs, but the kind that make the app feel unfinished. The real problem is they are completely avoidable, and they keep happening anyway.

The before

I used to write all of this inline. The logic lived in the component that triggered the mutation.

const { mutate } = useMutation({
  mutationFn: (data) => api.updateProfile(data),
});

const handleSubmit = (data) => {
  mutate(data, {
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: getCurrentUserKey() });
      toast.success('Profile updated');
      onClose();
    },
    onError: (error) => {
      toast.error(error.message ?? 'Something went wrong');
    },
  });
};

This is fine for one component. The problem is that if three different places in the app update a profile, you now have three chances to forget the invalidation, use a different error message, or skip the toast. And they will all drift over time.

The rule

The hook owns side effects, the caller owns UI reactions.

Toast notifications, cache invalidation, and error handling live in the hook. Closing a modal, redirecting, or updating local state happen at the call site via onSuccess and onError callbacks.

export const useSaveProfile = ({ onSuccess, onError }) => {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (data) => api.updateProfile(data),

    onSuccess: (data) => {
      queryClient.invalidateQueries({ queryKey: getCurrentUserKey() });
      toast.success('Profile saved');
      onSuccess?.(data);
    },

    onError: (error) => {
      toast.error(error.message ?? 'Failed to save profile');
      onError?.(error);
    },
  });
};

The call site gets much simpler.

const { mutate, isPending } = useSaveProfile({
  onSuccess: () => setOpen(false),
});

The hook handles the domain side. The component just reacts to the outcome without needing to know anything about queries or toasts.

What this actually fixes

Invalidation happens automatically, so stale data is no longer something you catch from a QA report. Success and error toasts are handled in one place, so they cannot be forgotten or inconsistent.

Every hook in the codebase follows the same shape, which makes them predictable to use and easy to skim. Components end up smaller too, because all the side effect logic is somewhere else.

When to break it

There are edge cases where the pattern does not hold cleanly. OTP inputs are a good example, where a backend error needs to appear under the input field rather than in a toast. The hook still passes the message through onError, but the component is responsible for rendering it in the right place.

Keeping it consistent

I added this to my CLAUDE.md with examples, so generated code follows the same shape. Without it, AI tools write mutations inline because that is the more common pattern in tutorials. With good CLAUDE.md rules, every hook gets the right pattern from the start.