Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions .agents/skills/building-blocks/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,13 @@ Read the rule file for each block you are about to implement. The summaries belo

### Data Fetching

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

### State Management

Expand Down
79 changes: 21 additions & 58 deletions .agents/skills/building-blocks/rules/mutation-hook.md
Original file line number Diff line number Diff line change
@@ -1,87 +1,50 @@
---
title: Mutation Hook
category: Data Fetching
layer: lib/api/
composedWith: query-keys-factory
layer: providers/
composedWith: mutation-options-factory, query-keys-factory
---

## Mutation Hook

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.
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.

### Constraints

- Error classes are co-located with the hook — they're reusable across feature slices, so they live with the mutation definition.
- 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.
- Invalidation belongs here — the mutation knows what resource it wrote to, so it owns cache coherence via `query-keys-factory`.
- ALWAYS use `use` prefix in names — `useXxxMutation`, never `xxxMutation`. File: `use-xxx-mutation.ts`.
- ALWAYS use `use` prefix — `useXxxMutation`, never `xxxMutation`. File: `use-xxx-mutation.ts`
- Compose from a `mutation-options-factory` via spread
- Invalidation belongs here — the hook knows what queries to invalidate via `query-keys-factory`
- When the consumer needs to extend `onSuccess` (e.g., close a modal after mutation), spread and chain the factory's callbacks

### Example

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

interface AddToCartPayload {
productId: number;
quantity?: number;
}

interface AddToCartDto {
cartId: number;
payload: AddToCartPayload;
}

export const useAddToCartMutation = () => {
export const useAddToCartMutation = (cartId: number) => {
const queryClient = useQueryClient();

const { mutateAsync, isPending } = useMutation<void, unknown, AddToCartDto>({
mutationFn: (body) =>
httpService.put<void, AddToCartPayload>(`carts/${body.cartId}`, {
productId: body.payload.productId,
quantity: body.payload.quantity,
}),
onSuccess: (_data, variables) => {
const { mutateAsync, isPending } = useMutation({
...addToCartMutationOptions,
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: cartQueryKeys.detail(variables.cartId),
queryKey: cartQueryKeys.detail(cartId),
});
},
});

const handler = async (cartId: number, payload: AddToCartPayload) => {
try {
return await mutateAsync({ cartId, payload });
} catch (e) {
Logger.error("Failed to add item to cart", e as Error);

if (httpService.isError(e) && e.message === "Unknown product") {
throw new UnknownProductError();
}
if (httpService.isError(e) && e.message === "Product not available") {
throw new ProductNotAvailableError();
}

throw new UnknownError();
}
const handler = async (payload: AddToCartPayload) => {
return mutateAsync({ cartId, payload });
};

return [handler, isPending] as const;
};
```

export class UnknownProductError extends Error {
constructor() {
super("Unknown product");
this.name = "UnknownProductError";
}
}
### References

export class ProductNotAvailableError extends Error {
constructor() {
super("Product not available");
this.name = "ProductNotAvailableError";
}
}
```
- `rules/mutation-options-factory.md` — raw factory pattern in `lib/api/`
- `rules/query-keys-factory.md` — cache key structure for invalidation
87 changes: 87 additions & 0 deletions .agents/skills/building-blocks/rules/mutation-options-factory.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
---
title: Mutation Options Factory
category: Data Fetching
layer: lib/api/
composedWith: mutation-hook
---

## Mutation Options Factory

Reusable `mutationOptions()` factory for a single API write operation. Contains `mutationFn` with domain error translation and logging.

### Constraints

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

### Example

```tsx
// src/lib/api/carts/add-to-cart/add-to-cart-mutation.ts
import { mutationOptions } from "@tanstack/react-query";
import { httpService } from "@/lib/http";
import { Logger } from "@/lib/logger";
import { UnknownError } from "@/lib/types/unknown-error";

interface AddToCartPayload {
productId: number;
quantity?: number;
}

interface AddToCartDto {
cartId: number;
payload: AddToCartPayload;
}

export const addToCartMutationOptions = mutationOptions({
mutationFn: async (body: AddToCartDto): Promise<void> => {
try {
await httpService.put<void, AddToCartPayload>(
`carts/${body.cartId}`,
body.payload
);
} catch (e) {
Logger.error("Failed to add item to cart", e as Error);
if (httpService.isError(e) && e.message === "Unknown product") {
throw new UnknownProductError();
}
if (httpService.isError(e) && e.message === "Product not available") {
throw new ProductNotAvailableError();
}
throw new UnknownError();
}
},
});

export class UnknownProductError extends Error {
constructor() {
super("Unknown product");
this.name = "UnknownProductError";
}
}

export class ProductNotAvailableError extends Error {
constructor() {
super("Product not available");
this.name = "ProductNotAvailableError";
}
}
```

```tsx
// ❌ Wrong — invalidation in factory
export const addToCartMutationOptions = mutationOptions({
mutationFn: async (body: AddToCartDto): Promise<void> => {
await httpService.put(`carts/${body.cartId}`, body.payload);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: cartQueryKeys.all });
},
});
```

### References

- `@src/lib/api/` — resource-organized API directory
- `rules/mutation-hook.md` — composing hook pattern in `providers/`
2 changes: 1 addition & 1 deletion .claude/rules/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ paths:

- `src/features/` — feature modules using feature slice architecture
- `src/lib/` — shared utilities, components, HTTP client, i18n, routing, theme
. `src/lib/api/` — centralized API layer: queryOptions factories, mutations, DTOs by resource
. `src/lib/api/` — centralized API layer: queryOptions factories, mutationOptions factories, DTOs by resource
- `src/lib/permissions/` — permission gating utilities. Available permissions: `@/features/authv2/models/permissions`
- `src/pages/` — route-level page components composing features
- `src/app/` — app-level config (App.tsx, Providers.tsx)
Expand Down
12 changes: 6 additions & 6 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,12 +73,12 @@ Each feature follows feature slice architecture patterns with four layers:

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

| Type | Suffix | Example |
| ------------- | ---------------- | ------------------------- |
| Query | `-query.ts` | `cart-products-query.ts` |
| Mutation hook | `-mutation.ts` | `add-to-cart-mutation.ts` |
| DTO interface | `-dto.ts` | `cart-product-dto.ts` |
| Query keys | `-query-keys.ts` | `cart-query-keys.ts` |
| Type | Suffix | Example |
| ---------------- | ---------------- | ------------------------- |
| Query options | `-query.ts` | `cart-products-query.ts` |
| Mutation options | `-mutation.ts` | `add-to-cart-mutation.ts` |
| DTO interface | `-dto.ts` | `cart-product-dto.ts` |
| Query keys | `-query-keys.ts` | `cart-query-keys.ts` |

## Key Patterns

Expand Down
25 changes: 24 additions & 1 deletion src/features/carts/providers/use-add-to-cart-mutation.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,28 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";

import { cartQueryKeys } from "@/lib/api/carts/cart-query-keys";
import {
addToCartMutationOptions,
type AddToCartPayload,
} from "@/lib/api/carts/{cart-id}/add-to-cart-mutation";

export {
useAddToCartMutation,
UnknownProductError,
ProductNotAvailableError,
} from "@/lib/api/carts/{cart-id}/add-to-cart-mutation";

export const useAddToCartMutation = () => {
const queryClient = useQueryClient();

const { mutateAsync, isPending } = useMutation({
...addToCartMutationOptions,
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: cartQueryKeys.all });
},
});

const handler = (cartId: string, payload: AddToCartPayload) =>
mutateAsync({ cartId, payload });

return [handler, isPending] as const;
};
18 changes: 17 additions & 1 deletion src/features/carts/providers/use-clear-cart-mutation.ts
Original file line number Diff line number Diff line change
@@ -1 +1,17 @@
export { useClearCartMutation } from "@/lib/api/carts/{cart-id}/clear-cart-mutation";
import { useMutation, useQueryClient } from "@tanstack/react-query";

import { cartQueryKeys } from "@/lib/api/carts/cart-query-keys";
import { clearCartMutationOptions } from "@/lib/api/carts/{cart-id}/clear-cart-mutation";

export const useClearCartMutation = () => {
const queryClient = useQueryClient();

const { mutateAsync, isPending } = useMutation({
...clearCartMutationOptions,
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: cartQueryKeys.all });
},
});

return [mutateAsync, isPending] as const;
};
12 changes: 11 additions & 1 deletion src/features/carts/providers/use-purchase-mutation.ts
Original file line number Diff line number Diff line change
@@ -1 +1,11 @@
export { usePurchaseMutation } from "@/lib/api/carts/{cart-id}/purchase-mutation";
import { useMutation } from "@tanstack/react-query";

import { purchaseMutationOptions } from "@/lib/api/carts/{cart-id}/purchase-mutation";

export const usePurchaseMutation = () => {
const { mutateAsync, isPending } = useMutation({
...purchaseMutationOptions,
});

return [mutateAsync, isPending] as const;
};
60 changes: 59 additions & 1 deletion src/features/marketing/providers/use-rate-product-mutation.ts
Original file line number Diff line number Diff line change
@@ -1 +1,59 @@
export { useRateProductMutation } from "@/lib/api/marketing/{product-id}/use-rate-product-mutation";
import { useMutation, useQueryClient } from "@tanstack/react-query";

import { marketingQueryKeys } from "@/lib/api/marketing/marketing-query-keys";
import type { MarketingProductDto } from "@/lib/api/marketing/{product-id}/marketing-product-dto";
import {
rateProductMutationOptions,
type RateProductDto,
} from "@/lib/api/marketing/{product-id}/rate-product-mutation";

interface MutationContext {
previous: MarketingProductDto | undefined;
productId: string;
}

export const useRateProductMutation = () => {
const queryClient = useQueryClient();

const { mutateAsync, isPending } = useMutation<
MarketingProductDto,
Error,
RateProductDto,
MutationContext
>({
...rateProductMutationOptions,
onMutate: async ({ productId, rating }) => {
const queryKey = marketingQueryKeys.product(productId);
await queryClient.cancelQueries({ queryKey });

const previous = queryClient.getQueryData<MarketingProductDto>(queryKey);

if (previous) {
const { rate: oldRate, count: oldCount } = previous.rating;
const newCount = oldCount + 1;
const newRate =
Math.round(((oldRate * oldCount + rating) / newCount) * 100) / 100;

queryClient.setQueryData<MarketingProductDto>(queryKey, {
...previous,
rating: { rate: newRate, count: newCount },
});
}

return { previous, productId };
},
onError: (_err, _vars, context) => {
if (context?.previous) {
queryClient.setQueryData(
marketingQueryKeys.product(context.productId),
context.previous
);
}
},
});

const handler = (productId: string, rating: number) =>
mutateAsync({ productId, rating });

return [handler, isPending] as const;
};
Loading
Loading