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 Requestsmutate
function is the function which executes mutation for real, and handle result after sending request to Servermutate
function do not clear and update query automatically itself. Querying Cache is not automatically updated, even if user callsmutate
function- But, if we use options like
onSuccess
,onError
,onSettled
, we can manage cache on our own usingconst 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 auseQuery
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 andinvalidateQueries
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
oruseSuspenseQuery
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, orerror
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 statestartTransition
:set or unsettle loading state using this function automatically during asynchronous worksLoadingSpinner
: display loading ui returning componentuseIsMountedRef
: prevent from callingsetLoading
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 usinguseMutation
- redirect to
routes.EXPENSE_TRACKER
after deleting - show
Toast
if 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,
};
};