Skip to content

Commit 7cc7eb4

Browse files
committed
refactor: refactor mutation hooks
1 parent c8e0949 commit 7cc7eb4

11 files changed

Lines changed: 173 additions & 145 deletions

File tree

.agents/skills/building-blocks/rules/mutation-hook.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ Server write hook composed from `mutation-options-factory`. Spreads the factory
1212
### Constraints
1313

1414
- ALWAYS use `use` prefix — `useXxxMutation`, never `xxxMutation`. File: `use-xxx-mutation.ts`
15-
- Compose from a `mutation-options-factory` via spread — do not inline `mutationFn` or error translation here
15+
- Compose from a `mutation-options-factory` via spread
1616
- Invalidation belongs here — the hook knows what queries to invalidate via `query-keys-factory`
1717
- When the consumer needs to extend `onSuccess` (e.g., close a modal after mutation), spread and chain the factory's callbacks
1818

.agents/skills/building-blocks/rules/mutation-options-factory.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ Reusable `mutationOptions()` factory for a single API write operation. Contains
1313

1414
- NEVER use `use` prefix — these are factories, not hooks. Name: `xxxMutationOptions`. File: `xxx-mutation.ts`
1515
- One factory per API endpoint. Co-locate with related query options in the same resource directory
16-
- Co-locate error classes with the factory. Map API errors exhaustively in `mutationFn` — translate, log, fall back to `UnknownError`
16+
- Co-locate mutation types (payload & dto) and error classes with the factory. Map API errors exhaustively in `mutationFn` — translate, log, fall back to `UnknownError`
1717

1818
### Example
1919

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,28 @@
1+
import { useMutation, useQueryClient } from "@tanstack/react-query";
2+
3+
import { cartQueryKeys } from "@/lib/api/carts/cart-query-keys";
4+
import {
5+
addToCartMutationOptions,
6+
type AddToCartPayload,
7+
} from "@/lib/api/carts/{cart-id}/add-to-cart-mutation";
8+
19
export {
2-
useAddToCartMutation,
310
UnknownProductError,
411
ProductNotAvailableError,
512
} from "@/lib/api/carts/{cart-id}/add-to-cart-mutation";
13+
14+
export const useAddToCartMutation = () => {
15+
const queryClient = useQueryClient();
16+
17+
const { mutateAsync, isPending } = useMutation({
18+
...addToCartMutationOptions,
19+
onSuccess: async () => {
20+
await queryClient.invalidateQueries({ queryKey: cartQueryKeys.all });
21+
},
22+
});
23+
24+
const handler = (cartId: string, payload: AddToCartPayload) =>
25+
mutateAsync({ cartId, payload });
26+
27+
return [handler, isPending] as const;
28+
};
Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,17 @@
1-
export { useClearCartMutation } from "@/lib/api/carts/{cart-id}/clear-cart-mutation";
1+
import { useMutation, useQueryClient } from "@tanstack/react-query";
2+
3+
import { cartQueryKeys } from "@/lib/api/carts/cart-query-keys";
4+
import { clearCartMutationOptions } from "@/lib/api/carts/{cart-id}/clear-cart-mutation";
5+
6+
export const useClearCartMutation = () => {
7+
const queryClient = useQueryClient();
8+
9+
const { mutateAsync, isPending } = useMutation({
10+
...clearCartMutationOptions,
11+
onSuccess: async () => {
12+
await queryClient.invalidateQueries({ queryKey: cartQueryKeys.all });
13+
},
14+
});
15+
16+
return [mutateAsync, isPending] as const;
17+
};
Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,11 @@
1-
export { usePurchaseMutation } from "@/lib/api/carts/{cart-id}/purchase-mutation";
1+
import { useMutation } from "@tanstack/react-query";
2+
3+
import { purchaseMutationOptions } from "@/lib/api/carts/{cart-id}/purchase-mutation";
4+
5+
export const usePurchaseMutation = () => {
6+
const { mutateAsync, isPending } = useMutation({
7+
...purchaseMutationOptions,
8+
});
9+
10+
return [mutateAsync, isPending] as const;
11+
};
Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,59 @@
1-
export { useRateProductMutation } from "@/lib/api/marketing/{product-id}/use-rate-product-mutation";
1+
import { useMutation, useQueryClient } from "@tanstack/react-query";
2+
3+
import { marketingQueryKeys } from "@/lib/api/marketing/marketing-query-keys";
4+
import type { MarketingProductDto } from "@/lib/api/marketing/{product-id}/marketing-product-dto";
5+
import {
6+
rateProductMutationOptions,
7+
type RateProductDto,
8+
} from "@/lib/api/marketing/{product-id}/rate-product-mutation";
9+
10+
interface MutationContext {
11+
previous: MarketingProductDto | undefined;
12+
productId: string;
13+
}
14+
15+
export const useRateProductMutation = () => {
16+
const queryClient = useQueryClient();
17+
18+
const { mutateAsync, isPending } = useMutation<
19+
MarketingProductDto,
20+
Error,
21+
RateProductDto,
22+
MutationContext
23+
>({
24+
...rateProductMutationOptions,
25+
onMutate: async ({ productId, rating }) => {
26+
const queryKey = marketingQueryKeys.product(productId);
27+
await queryClient.cancelQueries({ queryKey });
28+
29+
const previous = queryClient.getQueryData<MarketingProductDto>(queryKey);
30+
31+
if (previous) {
32+
const { rate: oldRate, count: oldCount } = previous.rating;
33+
const newCount = oldCount + 1;
34+
const newRate =
35+
Math.round(((oldRate * oldCount + rating) / newCount) * 100) / 100;
36+
37+
queryClient.setQueryData<MarketingProductDto>(queryKey, {
38+
...previous,
39+
rating: { rate: newRate, count: newCount },
40+
});
41+
}
42+
43+
return { previous, productId };
44+
},
45+
onError: (_err, _vars, context) => {
46+
if (context?.previous) {
47+
queryClient.setQueryData(
48+
marketingQueryKeys.product(context.productId),
49+
context.previous
50+
);
51+
}
52+
},
53+
});
54+
55+
const handler = (productId: string, rating: number) =>
56+
mutateAsync({ productId, rating });
57+
58+
return [handler, isPending] as const;
59+
};

src/lib/api/carts/{cart-id}/add-to-cart-mutation.ts

Lines changed: 11 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,41 @@
1-
import { useMutation } from "@tanstack/react-query";
1+
import { mutationOptions } from "@tanstack/react-query";
22

33
import { httpService } from "@/lib/http";
44
import { Logger } from "@/lib/logger";
55
import { UnknownError } from "@/lib/types/unknown-error";
66

7-
interface AddToCartPayload {
7+
export interface AddToCartPayload {
88
productId: string;
99
quantity?: number;
1010
}
1111

12-
interface AddToCartDto {
12+
export interface AddToCartDto {
1313
cartId: string;
1414
payload: AddToCartPayload;
1515
}
1616

17-
export const useAddToCartMutation = () => {
18-
const { mutateAsync, isPending } = useMutation<void, unknown, AddToCartDto>({
19-
mutationFn: (body) =>
20-
httpService.put<void, AddToCartPayload>(`carts/${body.cartId}`, {
21-
productId: body.payload.productId,
22-
quantity: body.payload.quantity,
23-
}),
24-
});
25-
26-
const handler = async (cartId: string, payload: AddToCartPayload) => {
17+
export const addToCartMutationOptions = mutationOptions({
18+
mutationFn: async (body: AddToCartDto): Promise<void> => {
2719
try {
28-
return await mutateAsync({ cartId, payload });
20+
await httpService.put<void, AddToCartPayload>(
21+
`carts/${body.cartId}`,
22+
body.payload
23+
);
2924
} catch (e) {
3025
Logger.error(
3126
"An error occurred during adding an item to the cart",
3227
e as Error
3328
);
34-
3529
if (httpService.isError(e) && e.message === "Unknown product") {
3630
throw new UnknownProductError();
3731
}
38-
3932
if (httpService.isError(e) && e.message === "Product not available") {
4033
throw new ProductNotAvailableError();
4134
}
42-
4335
throw new UnknownError();
4436
}
45-
};
46-
47-
return [handler, isPending] as const;
48-
};
37+
},
38+
});
4939

5040
export class UnknownProductError extends Error {
5141
constructor() {
Lines changed: 13 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,20 @@
1-
import { useMutation, useQueryClient } from "@tanstack/react-query";
1+
import { mutationOptions } from "@tanstack/react-query";
22

3-
import { cartQueryKeys } from "@/lib/api/carts/cart-query-keys";
43
import { httpService } from "@/lib/http";
54
import { Logger } from "@/lib/logger";
5+
import { UnknownError } from "@/lib/types/unknown-error";
66

7-
interface IClearCartValues {
7+
interface ClearCartDto {
88
cartId: string;
99
}
1010

11-
export const useClearCartMutation = () => {
12-
const queryClient = useQueryClient();
13-
const { mutateAsync, isPending } = useMutation<
14-
void,
15-
unknown,
16-
IClearCartValues
17-
>({
18-
mutationFn: (body) => httpService.delete(`carts/${body.cartId}`),
19-
});
20-
21-
const handler = (body: IClearCartValues) => {
22-
return mutateAsync(body)
23-
.then(async () => {
24-
await queryClient.invalidateQueries({ queryKey: cartQueryKeys.all });
25-
})
26-
.catch((e) => {
27-
Logger.error("An error occurred during clearing the cart", e as Error);
28-
29-
throw e;
30-
});
31-
};
32-
33-
return [handler, isPending] as const;
34-
};
11+
export const clearCartMutationOptions = mutationOptions({
12+
mutationFn: async (body: ClearCartDto): Promise<void> => {
13+
try {
14+
await httpService.delete(`carts/${body.cartId}`);
15+
} catch (e) {
16+
Logger.error("An error occurred during clearing the cart", e as Error);
17+
throw new UnknownError();
18+
}
19+
},
20+
});
Lines changed: 7 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,8 @@
1-
import { useState } from "react";
1+
import { mutationOptions } from "@tanstack/react-query";
22

3-
// todo: use msw to mock this feature
4-
export const usePurchaseMutation = () => {
5-
const [isLoading, setIsLoading] = useState(false);
6-
7-
const purchase = async () => {
8-
return new Promise<void>((resolve) => {
9-
setIsLoading(true);
10-
setTimeout(() => {
11-
resolve();
12-
setIsLoading(false);
13-
}, 400);
14-
});
15-
};
16-
17-
return [purchase, isLoading] as const;
18-
};
3+
// AIDEV-NOTE: stub implementation — replace mutationFn with real HTTP call once MSW mock is ready
4+
export const purchaseMutationOptions = mutationOptions({
5+
mutationFn: async (): Promise<void> => {
6+
await new Promise<void>((resolve) => setTimeout(resolve, 400));
7+
},
8+
});
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { mutationOptions } from "@tanstack/react-query";
2+
3+
import { httpService } from "@/lib/http";
4+
import { Logger } from "@/lib/logger";
5+
import { UnknownError } from "@/lib/types/unknown-error";
6+
7+
import type { MarketingProductDto } from "./marketing-product-dto";
8+
9+
export interface RateProductDto {
10+
productId: string;
11+
rating: number;
12+
}
13+
14+
export const rateProductMutationOptions = mutationOptions({
15+
mutationFn: async ({
16+
productId,
17+
rating,
18+
}: RateProductDto): Promise<MarketingProductDto> => {
19+
try {
20+
return await httpService.patch<MarketingProductDto, { rating: number }>(
21+
`marketing/products/${productId}/rate`,
22+
{ rating }
23+
);
24+
} catch (e) {
25+
Logger.error("An error occurred during rating the product", e as Error);
26+
throw new UnknownError();
27+
}
28+
},
29+
});

0 commit comments

Comments
 (0)