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>
This commit is contained in:
Émile Ré
2026-06-24 10:28:57 +02:00
parent e93faf4caa
commit ff966b462e
14 changed files with 684 additions and 122 deletions

View File

@@ -344,7 +344,7 @@ pages/organizations/third-parties/_components/ThirdPartyContactListItem.tsx
## `_lib` folder
Non-component code scoped to a subtree — hooks, utilities, constants, types — lives in a `_lib/` folder next to the pages that use it. The same hoisting rule as `_components/` applies: shared helpers move to the nearest common ancestor's `_lib/`; truly global helpers live in `src/lib/`. Files in `_lib/` use camelCase (`useThirdPartyFilters.ts`, `formatCurrency.ts`, `constants.ts`).
Non-component code scoped to a subtree — hooks, utilities, constants, types — lives in a `_lib/` folder next to the pages that use it. The same hoisting rule as `_components/` applies: shared helpers move to the nearest common ancestor's `_lib/`; truly global helpers live in `src/lib/`. Files in `_lib/` use camelCase (`useThirdPartyFilters.ts`, `formatCurrency.ts`, `constants.ts`). See [`contrib/claude/hooks.md`](hooks.md) for custom-hook conventions.
```text
// Good — feature-scoped helpers under _lib
@@ -356,6 +356,25 @@ pages/organizations/third-parties/
ThirdPartyListItem.tsx
```
## No barrel files
App code imports every module by its **explicit path** (`#/lib/relay/useMutation`, `#/lib/http/endpoint`). Do **not** add `index.ts` re-export barrels inside an app's `src/` to shorten import paths.
`index.ts` barrels are reserved for **package public entrypoints** (`packages/*`), where they define a real published API surface (`@probo/ui`, `@probo/relay`, …). An app has no published surface, so a barrel hides nothing — it only adds an indirection hop and invites Vite/tree-shaking and circular-import problems.
```text
// Bad — barrel inside the app just to shorten a path
src/lib/relay/
index.ts # export { useMutation } from "./useMutation"
useMutation.ts
// import { useMutation } from "#/lib/relay"
// Good — import the module directly; no index.ts
src/lib/relay/
useMutation.ts
// import { useMutation } from "#/lib/relay/useMutation"
```
## `_locales` folder
Translations are i18next catalogs in a `_locales/` folder, **one file per locale**, named by locale tag: `en-US.json`, `fr-FR.json`. See [`contrib/claude/i18n.md`](i18n.md) for key conventions and setup.

158
contrib/claude/hooks.md Normal file
View File

@@ -0,0 +1,158 @@
# 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.

View File

@@ -288,7 +288,34 @@ Every mutation **must** update the Relay store so the UI reflects changes immedi
### `useMutation`
Direct Relay hook for simple cases.
The project mutation primitive — awaitable, preserves every `UseMutationConfig` option, and routes success/error feedback through an injected notifier.
> Import `useMutation` from `#/lib/relay/useMutation`, never from `react-relay`. In compliance-portal this is enforced by a `no-restricted-imports` ESLint rule. See [`contrib/claude/hooks.md`](hooks.md#mutation-hooks).
#### Shared hook, app binding
The mechanics live in `@probo/relay` as `createUseMutation(useNotifier)` — a factory that wraps `react-relay`'s `useMutation` (promise wrapping, `onCompleted`/`onError` dispatch, `errorToast` semantics) but knows nothing about toasts or i18n. Each app binds it once to its own feedback stack via a `MutationNotifier` and re-exports the result as the canonical `useMutation`:
```tsx
// apps/compliance-portal/src/lib/relay/useMutation.ts — the only place feedback is wired
import { createUseMutation, type MutationNotifier } from "@probo/relay";
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 const useMutation = createUseMutation(useMutationNotifier);
```
This keeps `@probo/relay` free of UI and i18n dependencies (the toast system, `react-i18next`, and `formatError` stay in the app), while the awaitable behavior is shared. Pass `MutationFeedback` (`successMessage`, `errorToast`) to control notifications without writing `onCompleted`/`onError` by hand.
#### Naming convention