Files
probo/contrib/claude/hooks.md
Émile Ré ff966b462e Promote useMutation to the @probo/relay package
Extract the awaitable useMutation into @probo/relay as a
createUseMutation factory that delegates feedback to an injected
MutationNotifier, keeping the package free of UI and i18n
dependencies. compliance-portal binds it to its Base UI toast +
i18next + formatError stack and imports it by explicit path
(#/lib/relay/useMutation), dropping the lone intra-app barrel; a
compliance-portal-scoped no-restricted-imports rule forbids
react-relay's useMutation.

Bring packages/relay and packages/routes into the shared ESLint
scope and fix the violations that surfaced, and deprecate the
legacy withQueryRef / loaderFromQueryLoader helpers. Document the
shared-hook pattern and the "index.ts for package entrypoints only"
rule in the relay, hooks, and app-arborescence guides.

Signed-off-by: Émile Ré <emile@probo.com>
2026-06-26 18:52:05 +02:00

159 lines
6.9 KiB
Markdown

# Custom hooks
Custom hooks encapsulate reusable behavior (data wiring, derived state, event logic). This guide covers **where they live**, **how they are named**, and the **mutation hook pattern** — an awaitable wrapper over Relay's `useMutation` that preserves every option and automates error feedback.
## Related guides
| Topic | Guide |
|-------|--------|
| `_lib` / `_components` placement, hoisting | [`contrib/claude/app-arborescence.md`](app-arborescence.md) |
| Mutations, store updates, connection directives | [`contrib/claude/relay.md`](relay.md) |
| Toasts, user feedback | [`contrib/claude/ui.md`](ui.md#user-feedback-toasts) |
| i18next translation keys | [`contrib/claude/i18n.md`](i18n.md) |
## Placement
- **Feature-scoped hooks** live in the feature's `_lib/` folder, next to the pages that use them (`pages/organizations/measures/_lib/useDeleteMeasure.ts`).
- **Shared hooks** used across features are hoisted to the **nearest common ancestor's** `_lib/`, and only **app-wide** primitives (used everywhere) live in the top-level `src/lib/`.
- Promote a hook **when a second feature needs it**, not preemptively — the same rule as `_components/` (see [`app-arborescence.md`](app-arborescence.md)).
```text
// Feature-scoped
pages/organizations/measures/_lib/useDeleteMeasure.ts
// Shared across a feature area
pages/organizations/_lib/useOrganizationId.ts
// App-wide primitive
src/lib/relay/useMutation.ts
```
## Shape and naming
- **One primary hook per file**; the file is camelCase and matches the hook name (`useDeleteMeasure.ts``useDeleteMeasure`).
- Hooks are **`function` declarations**, named `use…` (see [`react-components.md`](react-components.md#component-shape)).
- Colocate a hook's `graphql` operation in the same file.
- A hook returns either a value, or a tuple when it mirrors a React/Relay primitive (`[commit, isInFlight]`).
## Mutation hooks
All mutations go through the shared **`useMutation`** primitive. Its mechanics live in `@probo/relay` as the `createUseMutation(useNotifier)` factory, and each app binds it once in `src/lib/relay/useMutation.ts`. It wraps Relay's `useMutation` to:
1. Return an **awaitable** commit that resolves with the mutation **response** (so callers can `await` and continue only on success).
2. **Preserve every `UseMutationConfig` option** (`variables`, `connections`, `updater`, `optimisticResponse`, `onCompleted`, `onError`, …) by spreading the caller's config.
3. **Automate feedback**: on failure it notifies (via the app's injected `MutationNotifier` — Base UI toast + `formatError`) **and** rejects the promise — controllable per call through a `MutationFeedback` options object.
### Always import `useMutation` from `#/lib/relay/useMutation`
Our `useMutation` intentionally shadows `react-relay`'s. **Import it only from `#/lib/relay/useMutation`; never import `useMutation` from `react-relay` directly** (enforced in compliance-portal by a `no-restricted-imports` ESLint rule). This guarantees one consistent entrypoint with awaitable results and automatic error handling everywhere.
```ts
// Bad — raw Relay hook (no await, no auto error handling)
import { useMutation } from "react-relay";
// Good — the project primitive
import { useMutation } from "#/lib/relay/useMutation";
```
### The primitive: shared factory + app binding
The factory lives in `@probo/relay` and stays free of UI/i18n dependencies — it delegates rendering to an injected `MutationNotifier` (`createUseMutation` source: [`packages/relay/src/useMutation.ts`](../../packages/relay/src/useMutation.ts)). The app binds it once to its own toast + i18n + `formatError` stack:
```ts
// src/lib/relay/useMutation.ts — the only place feedback is wired
import { Toast } from "@base-ui/react/toast";
import { formatError, type GraphQLError } from "@probo/helpers";
import { createUseMutation, type MutationNotifier } from "@probo/relay";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
function useMutationNotifier(): MutationNotifier {
const toast = Toast.useToastManager();
const { t } = useTranslation();
return useMemo<MutationNotifier>(
() => ({
notifySuccess: (title) => toast.add({ title, type: "success" }),
notifyError: (error, title) => {
const finalTitle = title ?? t("common.error");
toast.add({
title: finalTitle,
description: formatError(finalTitle, error as GraphQLError),
type: "error",
});
},
}),
[toast, t],
);
}
export type { MutationFeedback } from "@probo/relay";
export const useMutation = createUseMutation(useMutationNotifier);
```
### Domain mutation hook (colocated `_lib/`)
A feature hook wraps the primitive with its operation and default feedback. Name the hook after the action; name the destructured commit function after the tagged node minus `Mutation` (see [`relay.md`](relay.md#naming-convention)).
```ts
// pages/organizations/measures/_lib/useDeleteMeasure.ts
import { graphql } from "relay-runtime";
import { useTranslation } from "react-i18next";
import { useMutation } from "#/lib/relay/useMutation";
import type { DeleteMeasureMutation } from "#/__generated__/core/DeleteMeasureMutation.graphql";
const deleteMeasureMutation = graphql`
mutation DeleteMeasureMutation($input: DeleteMeasureInput!, $connections: [ID!]!) {
deleteMeasure(input: $input) {
deletedMeasureId @deleteEdge(connections: $connections)
}
}
`;
export function useDeleteMeasure() {
const { t } = useTranslation();
return useMutation<DeleteMeasureMutation>(deleteMeasureMutation, {
successMessage: t("measures.deleted"),
errorToast: t("measures.deleteFailed"),
});
}
```
### Usage — await the result
```tsx
const [deleteMeasure, isDeleting] = useDeleteMeasure();
// Default: awaits the response; on failure it toasts AND throws.
async function onConfirm() {
await deleteMeasure({ variables: { input: { measureId }, connections: [connectionId] } });
navigate(".."); // only runs on success
}
// Opt out of the auto-toast to handle the error yourself:
try {
const result = await deleteMeasure({ variables }, { errorToast: false });
// use result.deleteMeasure.deletedMeasureId …
} catch (error) {
// custom handling
}
```
### Do / don't
```text
// Bad — legacy wrappers (removed in v2)
useMutationWithToasts(...) // resolves to void, loses the response; v1 toast + __
useMutationWithIncrement(...) // callback-style, not awaitable
promisifyMutation(commit) // standalone wrapper, disconnected from the hook
// Bad — importing the raw Relay hook
import { useMutation } from "react-relay";
// Good — one primitive, awaitable, options preserved, auto error handling
import { useMutation } from "#/lib/relay/useMutation";
```
Prefer the declarative store directives (`@appendEdge`, `@deleteEdge`) and the patterns in [`relay.md`](relay.md) for *what* the mutation does to the store; this hook only governs *how* it is invoked and how its errors surface.