This article started from questioning whether I must use optimistic updates of Server State with
useMutationin 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
useMutationis used to handle POST, PUT, DELETE Requestsmutatefunction is the function which executes mutation for real, and handle result after sending request to Servermutatefunction do not clear and update query automatically itself. Querying Cache is not automatically updated, even if user callsmutatefunction- But, if we use options like
onSuccess,onError,onSettled, we can manage cache on our own usingconst queryClient = useQueryClient() - using
onMutateoption 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
onSettledoption 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 auseQuerycall, triggering a refetch) - In other words,
invalidateQueriesdoes 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
useMutationhook andinvalidateQueriesat 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
useQueryoruseSuspenseQueryimmediately 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
useMutationpossibly adds unnecessary complexity
- code readability
try - catchwill 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
loadingstates, orerrorstates 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 statestartTransition:set or unsettle loading state using this function automatically during asynchronous worksLoadingSpinner: display loading ui returning componentuseIsMountedRef: prevent from callingsetLoadingif 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
Toastmessages on screen - only invalidate queries
My case is quite desirable using
useLoading, instead of usinguseMutation
- redirect to
routes.EXPENSE_TRACKERafter deleting - show
Toastif result is success or Error 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,
};
};