Skip to content

Commit ee0f68f

Browse files
authored
Merge pull request #58 from bartstc/chore/mutation-options-factory
chore: add mutation-options-factory building block
2 parents c5f33a6 + 7cc7eb4 commit ee0f68f

14 files changed

Lines changed: 293 additions & 214 deletions

File tree

.agents/skills/building-blocks/SKILL.md

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,13 @@ Read the rule file for each block you are about to implement. The summaries belo
1515

1616
### Data Fetching
1717

18-
| Block | Layer | Summary | File |
19-
| ----------------------- | ------------ | -------------------------------------------------------------------------------------------------------------------- | -------------------------------- |
20-
| `mutation-hook` | `providers/` | Server write via `useMutation` with domain error translation and cache invalidation. Returns `[handler, isPending]`. | `rules/mutation-hook.md` |
21-
| `query-options-factory` | `lib/api/` | Reusable `queryOptions()` factories — no hooks, no `useQuery`. Hook composition belongs in `providers/`. | `rules/query-options-factory.md` |
22-
| `query-keys-factory` | `lib/api/` | Hierarchical `as const` key tuples per resource. Single source of truth for cache invalidation. | `rules/query-keys-factory.md` |
23-
| `dto-model` | `lib/api/` | TypeScript interfaces mirroring the raw API wire format. No transformations. | `rules/dto-model.md` |
18+
| Block | Layer | Summary | File |
19+
| -------------------------- | ------------ | --------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- |
20+
| `mutation-options-factory` | `lib/api/` | Reusable `mutationOptions()` factories with domain error translation. Composed by `mutation-hook` in `providers/`. | `rules/mutation-options-factory.md` |
21+
| `mutation-hook` | `providers/` | Server write via `useMutation` composed from `mutation-options-factory`, with cache invalidation. Returns `[handler, isPending]`. | `rules/mutation-hook.md` |
22+
| `query-options-factory` | `lib/api/` | Reusable `queryOptions()` factories — no hooks, no `useQuery`. Hook composition belongs in `providers/`. | `rules/query-options-factory.md` |
23+
| `query-keys-factory` | `lib/api/` | Hierarchical `as const` key tuples per resource. Single source of truth for cache invalidation. | `rules/query-keys-factory.md` |
24+
| `dto-model` | `lib/api/` | TypeScript interfaces mirroring the raw API wire format. No transformations. | `rules/dto-model.md` |
2425

2526
### State Management
2627

Lines changed: 21 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,87 +1,50 @@
11
---
22
title: Mutation Hook
33
category: Data Fetching
4-
layer: lib/api/
5-
composedWith: query-keys-factory
4+
layer: providers/
5+
composedWith: mutation-options-factory, query-keys-factory
66
---
77

88
## Mutation Hook
99

10-
Server write operations wrapped in `useMutation` with domain error translation and cache invalidation via `query-keys-factory`. Returns a `[handler, isPending]` tuple: the handler encapsulates the async call + error mapping.
10+
Server write hook composed from `mutation-options-factory`. Spreads the factory options and adds e.g. cache invalidation or custom error handling. Returns a `[handler, isPending]` tuple. Lives in `providers/`the data access gateway for the feature slice.
1111

1212
### Constraints
1313

14-
- Error classes are co-located with the hook — they're reusable across feature slices, so they live with the mutation definition.
15-
- Keep the handler's error mapping exhaustive: catch API errors, translate to typed domain errors, fall back to `UnknownError`. Components should never see raw HTTP errors.
16-
- Invalidation belongs here — the mutation knows what resource it wrote to, so it owns cache coherence via `query-keys-factory`.
17-
- ALWAYS use `use` prefix in names — `useXxxMutation`, never `xxxMutation`. File: `use-xxx-mutation.ts`.
14+
- ALWAYS use `use` prefix — `useXxxMutation`, never `xxxMutation`. File: `use-xxx-mutation.ts`
15+
- Compose from a `mutation-options-factory` via spread
16+
- Invalidation belongs here — the hook knows what queries to invalidate via `query-keys-factory`
17+
- When the consumer needs to extend `onSuccess` (e.g., close a modal after mutation), spread and chain the factory's callbacks
1818

1919
### Example
2020

2121
```tsx
22+
// src/features/carts/providers/use-add-to-cart-mutation.ts
2223
import { useMutation, useQueryClient } from "@tanstack/react-query";
23-
import { httpService } from "@/lib/http"; // project HTTP client
24-
import { Logger } from "@/lib/logger";
25-
import { UnknownError } from "@/lib/types/unknown-error";
26-
import { cartQueryKeys } from "@/lib/api/carts/cart-query-keys"; // query-keys-factory
24+
import { addToCartMutationOptions } from "@/lib/api/carts/add-to-cart/add-to-cart-mutation";
25+
import { cartQueryKeys } from "@/lib/api/carts/cart-query-keys";
2726

28-
interface AddToCartPayload {
29-
productId: number;
30-
quantity?: number;
31-
}
32-
33-
interface AddToCartDto {
34-
cartId: number;
35-
payload: AddToCartPayload;
36-
}
37-
38-
export const useAddToCartMutation = () => {
27+
export const useAddToCartMutation = (cartId: number) => {
3928
const queryClient = useQueryClient();
4029

41-
const { mutateAsync, isPending } = useMutation<void, unknown, AddToCartDto>({
42-
mutationFn: (body) =>
43-
httpService.put<void, AddToCartPayload>(`carts/${body.cartId}`, {
44-
productId: body.payload.productId,
45-
quantity: body.payload.quantity,
46-
}),
47-
onSuccess: (_data, variables) => {
30+
const { mutateAsync, isPending } = useMutation({
31+
...addToCartMutationOptions,
32+
onSuccess: () => {
4833
queryClient.invalidateQueries({
49-
queryKey: cartQueryKeys.detail(variables.cartId),
34+
queryKey: cartQueryKeys.detail(cartId),
5035
});
5136
},
5237
});
5338

54-
const handler = async (cartId: number, payload: AddToCartPayload) => {
55-
try {
56-
return await mutateAsync({ cartId, payload });
57-
} catch (e) {
58-
Logger.error("Failed to add item to cart", e as Error);
59-
60-
if (httpService.isError(e) && e.message === "Unknown product") {
61-
throw new UnknownProductError();
62-
}
63-
if (httpService.isError(e) && e.message === "Product not available") {
64-
throw new ProductNotAvailableError();
65-
}
66-
67-
throw new UnknownError();
68-
}
39+
const handler = async (payload: AddToCartPayload) => {
40+
return mutateAsync({ cartId, payload });
6941
};
7042

7143
return [handler, isPending] as const;
7244
};
45+
```
7346

74-
export class UnknownProductError extends Error {
75-
constructor() {
76-
super("Unknown product");
77-
this.name = "UnknownProductError";
78-
}
79-
}
47+
### References
8048

81-
export class ProductNotAvailableError extends Error {
82-
constructor() {
83-
super("Product not available");
84-
this.name = "ProductNotAvailableError";
85-
}
86-
}
87-
```
49+
- `rules/mutation-options-factory.md` — raw factory pattern in `lib/api/`
50+
- `rules/query-keys-factory.md` — cache key structure for invalidation
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
---
2+
title: Mutation Options Factory
3+
category: Data Fetching
4+
layer: lib/api/
5+
composedWith: mutation-hook
6+
---
7+
8+
## Mutation Options Factory
9+
10+
Reusable `mutationOptions()` factory for a single API write operation. Contains `mutationFn` with domain error translation and logging.
11+
12+
### Constraints
13+
14+
- NEVER use `use` prefix — these are factories, not hooks. Name: `xxxMutationOptions`. File: `xxx-mutation.ts`
15+
- One factory per API endpoint. Co-locate with related query options in the same resource directory
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`
17+
18+
### Example
19+
20+
```tsx
21+
// src/lib/api/carts/add-to-cart/add-to-cart-mutation.ts
22+
import { mutationOptions } from "@tanstack/react-query";
23+
import { httpService } from "@/lib/http";
24+
import { Logger } from "@/lib/logger";
25+
import { UnknownError } from "@/lib/types/unknown-error";
26+
27+
interface AddToCartPayload {
28+
productId: number;
29+
quantity?: number;
30+
}
31+
32+
interface AddToCartDto {
33+
cartId: number;
34+
payload: AddToCartPayload;
35+
}
36+
37+
export const addToCartMutationOptions = mutationOptions({
38+
mutationFn: async (body: AddToCartDto): Promise<void> => {
39+
try {
40+
await httpService.put<void, AddToCartPayload>(
41+
`carts/${body.cartId}`,
42+
body.payload
43+
);
44+
} catch (e) {
45+
Logger.error("Failed to add item to cart", e as Error);
46+
if (httpService.isError(e) && e.message === "Unknown product") {
47+
throw new UnknownProductError();
48+
}
49+
if (httpService.isError(e) && e.message === "Product not available") {
50+
throw new ProductNotAvailableError();
51+
}
52+
throw new UnknownError();
53+
}
54+
},
55+
});
56+
57+
export class UnknownProductError extends Error {
58+
constructor() {
59+
super("Unknown product");
60+
this.name = "UnknownProductError";
61+
}
62+
}
63+
64+
export class ProductNotAvailableError extends Error {
65+
constructor() {
66+
super("Product not available");
67+
this.name = "ProductNotAvailableError";
68+
}
69+
}
70+
```
71+
72+
```tsx
73+
// ❌ Wrong — invalidation in factory
74+
export const addToCartMutationOptions = mutationOptions({
75+
mutationFn: async (body: AddToCartDto): Promise<void> => {
76+
await httpService.put(`carts/${body.cartId}`, body.payload);
77+
},
78+
onSuccess: () => {
79+
queryClient.invalidateQueries({ queryKey: cartQueryKeys.all });
80+
},
81+
});
82+
```
83+
84+
### References
85+
86+
- `@src/lib/api/` — resource-organized API directory
87+
- `rules/mutation-hook.md` — composing hook pattern in `providers/`

.claude/rules/architecture.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ paths:
1010

1111
- `src/features/` — feature modules using feature slice architecture
1212
- `src/lib/` — shared utilities, components, HTTP client, i18n, routing, theme
13-
. `src/lib/api/` — centralized API layer: queryOptions factories, mutations, DTOs by resource
13+
. `src/lib/api/` — centralized API layer: queryOptions factories, mutationOptions factories, DTOs by resource
1414
- `src/lib/permissions/` — permission gating utilities. Available permissions: `@/features/authv2/models/permissions`
1515
- `src/pages/` — route-level page components composing features
1616
- `src/app/` — app-level config (App.tsx, Providers.tsx)

docs/architecture.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -73,12 +73,12 @@ Each feature follows feature slice architecture patterns with four layers:
7373

7474
**File naming in `src/lib/api/`:**
7575

76-
| Type | Suffix | Example |
77-
| ------------- | ---------------- | ------------------------- |
78-
| Query | `-query.ts` | `cart-products-query.ts` |
79-
| Mutation hook | `-mutation.ts` | `add-to-cart-mutation.ts` |
80-
| DTO interface | `-dto.ts` | `cart-product-dto.ts` |
81-
| Query keys | `-query-keys.ts` | `cart-query-keys.ts` |
76+
| Type | Suffix | Example |
77+
| ---------------- | ---------------- | ------------------------- |
78+
| Query options | `-query.ts` | `cart-products-query.ts` |
79+
| Mutation options | `-mutation.ts` | `add-to-cart-mutation.ts` |
80+
| DTO interface | `-dto.ts` | `cart-product-dto.ts` |
81+
| Query keys | `-query-keys.ts` | `cart-query-keys.ts` |
8282

8383
## Key Patterns
8484

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+
};

0 commit comments

Comments
 (0)