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

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.

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-level src/lib/.
  • Promote a hook when a second feature needs it, not preemptively — the same rule as _components/ (see app-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.tsuseDeleteMeasure).
  • Hooks are function declarations, named use… (see react-components.md).
  • 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.

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