Is useMutation hook necessary to update the server state on the UI??

Mar 11, 2025

This article started from questioning whether I must use optimistic updates of Server State with useMutation in my project Tekpod

1. what is useMutation and invalidateQueries in tanstack Query(React-Query)?

useMutation

The returned function mutate from useMutation doesn’t have an effect on queryClient.invalidateQueries

  • useMutation is used to handle POST, PUT, DELETE Requests
  • mutate function is the function which executes mutation for real, and handle result after sending request to Server
  • mutate function do not clear and update query automatically itself. Querying Cache is not automatically updated, even if user calls mutate function
  • But, if we use options like onSuccess, onError, onSettled, we can manage cache on our own using const queryClient = useQueryClient()
  • using onMutate option allows us to use Optimistic updates

Optimistic Updates : The mutation will updates the cache preemptively assuming success, and then rolls back the changes if the mutation fails.


Example: useMutation Hook with Optimistic Update

const { mutate } = useMutation({
  mutationFn: (newData) => api.post("/data", newData),
  async onMutate(variables) {
    await queryClient.cancelQueries({ queryKey: queryKey[$QUERYKEYNAME] });

    const previousData = queryClient.getQueryData(queryKey[$QUERYKEYNAME]);

    if (previousData) {
      queryClient.setQueryData(queryKey[$QUERYKEYNAME], $updateFn(variables));
    }

    return { previousData };
  },
  onSuccess: () => {
    addToast(toastData);
  },
  onError: () => {
    if (context?.previousData) {
      console.error(error);
      queryClient.setQueryData(queryKey[$QUERYKEYNAME], context?.previousData);
      addToast(toastData);
    }
  },
  onSettled: () => {
    queryClient.invalidateQueries({ queryKey: queryKey[$QUERYKEYNAME] });
  },
});

➡️ mutate

  • only request to handle data on Server, but onSettled option callback do cache update

➡️ queryClient.invalidateQueries

  • When the cache is invalidated, the corresponding query is marked as stale, and the next time that query is used, it will automatically fetch the data again (e.g. during a useQuery call, triggering a refetch)
  • In other words, invalidateQueries does not directly update the cache or fetch data. It simply invalidates the cache, prompting TanStack Query to fetch fresh data when needed.

Desirable pattern is using useMutation hook and invalidateQueries at the same time, like above example.

2. Let’s say what if we don’t need optimistic updates?

Case 1 : Optimistic Updates in need

  • show Edit / Delete Results instantly on same page

Case 2 : No need

  • When we navigate to another page which uses useQuery or useSuspenseQuery immediately after a mutation. So there is no need to instantly reflect the UI state.

3. what’s the positive effect of not using useMutation, using manage cache manually instead?

  • minimize overhead
    • useMutation possibly adds unnecessary complexity
  • code readability
    • try - catch will be more readable codes for simple asynchronous server logics

4. Judge the cases where we use useMutation

recommendation to use

  • When you need to update the UI immediately after a data change on the same screen (including optimistic updates).
  • When you need to reflect loading states, or error states in the UI.
  • When handling complex side effects (such as cache updates, notifications, additional API calls, etc.).
  • When the same mutation logic needs to be reused in multiple places.

not necessary to use

  • When the mutation is followed by a redirect, so UI updates are not needed.
  • When you only need to display a simple success/failure message.
  • When all you need is to invalidate the cache (e.g., by calling invalidateQueries).

5. If we don’t use useMutation hook, then how can we deal with state management and UI feedback properly?

In my case, I built useLoading hooks for asynchronous request to Server

const useLoading = () => {
  const [loading, setLoading] = useState<boolean>(false);
  const ref = useIsMountedRef();
  const startTransition = async <T,>(promise: Promise<T>): Promise<T> => {
    try {
      setLoading(true);

      const data = await promise;
      return data;
    } finally {
      if (ref.isMounted) {
        setLoading(false);
      }
    }
  };

  return { Loading: <LoadingSpinner />, isLoading: loading, startTransition };
};

key features

  • loading : manage loading based on state
  • startTransition :set or unsettle loading state using this function automatically during asynchronous works
  • LoadingSpinner : display loading ui returning component
  • useIsMountedRef : prevent from calling setLoading if component is being unmounted (prevent memory leak)



Use-Case on ExpenseTrackerByMonthItemPage

// styling with Emotion

const ExpenseTrackerByMonthItemPage = () => {
  const queryClient = useQueryClient();
  const {
    state: { payment, currentDate },
  } = useLocation();

  const { startTransition, Loading, isLoading } = useLoading();
  const { addToast } = useToastStore();
  const navigate = useNavigate();

  const handlePaymentDelete = async () => {
    try {
      await startTransition(removePayment({ id: payment.id }));

      addToast(toastData.EXPENSE_TRACKER.REMOVE.SUCCESS);
      navigate(routes.EXPENSE_TRACKER, { state: { currentDate } });
    } catch (e) {
      console.error(e);
      addToast(toastData.EXPENSE_TRACKER.REMOVE.ERROR);
    } finally {
      queryClient.invalidateQueries({
        queryKey: [...queryKey.EXPENSE_TRACKER, currentDate],
      });
    }
  };

  return (
    <Container>
      <MainContent>
        <PaymentMethod>
          <WonIconWrapper>
            {payment.payment_method === "Card" ? (
              <BsFillCreditCardFill size="14" />
            ) : (
              <FaWonSign size="14" />
            )}
          </WonIconWrapper>
          <PaymentMethodType>{payment.payment_method}</PaymentMethodType>
        </PaymentMethod>
        <PriceGroup>
          <Price>
            {monetizeWithSeparator(payment.priceIntegerPart)}
            {payment.priceDecimalPart.length
              ? `.${payment.priceDecimalPart}`
              : ""}
          </Price>
          <PriceUnit>{payment.price_unit}</PriceUnit>
        </PriceGroup>
      </MainContent>

      <Detail>
        <Place>
          <dt>Place</dt>
          <dd>{payment.place}</dd>
        </Place>
        <Bank>
          <dt>Bank</dt>
          <dd>{payment.bank}</dd>
        </Bank>
        <TransactionDate>
          <dt>Transaction Date</dt>
          <dd>{format(currentDate)}</dd>
        </TransactionDate>
      </Detail>

      <DeleteButton type="button" onClick={handlePaymentDelete}>
        {isLoading ? Loading : "Delete"}
      </DeleteButton>
    </Container>
  );
};

Decent Cases to use useLoading

  • redirect after deleting(DELETE)
  • only SUCCESS / FAILURE Toast messages on screen
  • only invalidate queries

My case is quite desirable using useLoading, instead of using useMutation

  1. redirect to routes.EXPENSE_TRACKER after deleting
  2. show Toast if result is success or Error
  3. onSettled - invalidate queries

6. Let's think about upscaling useLoading

(1) Managing Loading States per Task

If multiple tasks are running simultaneously, we can extend the functionality to manage loading states separately for each tasks

const useLoading = () => {
  const [loadings, setLoadings] = useState<Record<string, boolean>>({});
  const ref = useIsMountedRef();

  const startTransition = async <T,>(
    key: string,
    promise: Promise<T>
  ): Promise<T> => {
    try {
      setLoadings((prev) => ({ ...prev, [key]: true }));
      const data = await promise;
      return data;
    } finally {
      if (ref.isMounted) {
        setLoadings((prev) => ({ ...prev, [key]: false }));
      }
    }
  };

  return {
    Loading: <LoadingSpinner />,
    isLoading: (key: string) => !!loadings[key],
    startTransition,
  };
};

(2) Adding Error State Management

Either return error from startTransition or manage error states within the hook

const useLoading = () => {
  const [loading, setLoading] = useState<boolean>(false);
  const [error, setError] = useState<Error | null>(null);
  const ref = useIsMountedRef();

  const startTransition = async <T,>(promise: Promise<T>): Promise<T> => {
    try {
      setLoading(true);
      setError(null);
      const data = await promise;
      return data;
    } catch (err) {
      setError(err instanceof Error ? err : new Error("Unknown error"));
      throw err; // Throw the error so that the caller can handle it
    } finally {
      if (ref.isMounted) {
        setLoading(false);
      }
    }
  };

  return {
    Loading: <LoadingSpinner />,
    isLoading: loading,
    error,
    startTransition,
  };
};

(3) Adding Task Cancellation

We can enable cancellation of asynchronous tasks using AbortController

const useLoading = () => {
  const [loading, setLoading] = useState<boolean>(false);
  const ref = useIsMountedRef();
  const abortControllerRef = useRef<AbortController | null>(null);

  const startTransition = async <T,>(promise: Promise<T>): Promise<T> => {
    abortControllerRef.current?.abort(); // cancel previous request
    abortControllerRef.current = new AbortController();

    try {
      setLoading(true);
      const data = await promise;
      return data;
    } finally {
      if (ref.isMounted) {
        setLoading(false);
      }
      abortControllerRef.current = null;
    }
  };

  const cancel = () => {
    abortControllerRef.current?.abort();
  };

  return {
    Loading: <LoadingSpinner />,
    isLoading: loading,
    startTransition,
    cancel,
  };
};