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>
6.9 KiB
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 |
| Mutations, store updates, connection directives | contrib/claude/relay.md |
| Toasts, user feedback | contrib/claude/ui.md |
| i18next translation keys | contrib/claude/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-levelsrc/lib/. - Promote a hook when a second feature needs it, not preemptively — the same rule as
_components/(seeapp-arborescence.md).
// 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
functiondeclarations, nameduse…(seereact-components.md). - Colocate a hook's
graphqloperation 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:
- Return an awaitable commit that resolves with the mutation response (so callers can
awaitand continue only on success). - Preserve every
UseMutationConfigoption (variables,connections,updater,optimisticResponse,onCompleted,onError, …) by spreading the caller's config. - Automate feedback: on failure it notifies (via the app's injected
MutationNotifier— Base UI toast +formatError) and rejects the promise — controllable per call through aMutationFeedbackoptions 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.
// 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). The app binds it once to its own toast + i18n + formatError stack:
// 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).
// 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
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
// 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 for what the mutation does to the store; this hook only governs how it is invoked and how its errors surface.