Skip to content

Template: Add a new mutation hook

Status: Reference

Task

Add a new mutation hook for <domain operation> (e.g. "update club name", "create dues record").

Pre-implementation checks

  1. Grep for similar mutation: grep -rn "useUpdate<X>\|useCreate<X>\|use<X>Status" packages/app/src/presentation/hooks/mutations/. If a similar mutation exists, justify creating a new one OR extend the existing.
  2. Confirm port + adapter exist: the underlying repository method must already exist in packages/app/src/ports/ and be implemented in packages/app/src/adapters/supabase/.
  3. Identify cache keys affected: what query keys does this mutation invalidate or patch? List them.
  4. Decide: optimistic or not? If the target row is already in cache, this should be optimistic (applyOptimisticPatches). If it creates a row with a server-assigned ID, it shouldn't be optimistic.

Files in scope

  • New: packages/app/src/presentation/hooks/mutations/use-<verb>-<noun>.ts
  • Possibly: packages/app/src/index.ts (if hook needs to be exported via barrel)
  • Possibly: packages/app/src/registry.ts (only if a new repository method is needed)

Files OUT of scope

  • Adapter changes — those go in a separate, prior commit
  • UI changes — those consume this hook in a separate, later commit

Implementation pattern

If non-optimistic (factory):

ts
import { <repo> } from '@/registry';
import { <Keys> } from '@/query-keys';
import { t } from '@/config';
import { createMutationHook } from '@/presentation/hooks/create-mutation-hook';
import type { <Entity>, <Input> } from '@/domain';

/**
 * @optimistic no — server assigns ID
 * @offline queue-and-replay (Phase 14d)
 * @invalidates <Keys>.all
 */
export const use<Verb><Noun> = createMutationHook<<Input>, <Entity>>({
  mutationFn: (input) => <repo>.<method>(input),
  successMessage: () => t().<scope>.toast.<key>,
  errorMessage: () => t().errors.<key>,
  invalidateKeys: () => [<Keys>.all],
});

If optimistic (manual):

ts
import { useMutation, useQueryClient, type UseMutationResult } from '@tanstack/react-query';
import { <repo> } from '@/registry';
import { <Keys> } from '@/query-keys';
import { t } from '@/config';
import { showToast } from '@/presentation/stores/toast.store';
import { haptic } from '@/presentation/utils/haptics';
import {
  applyOptimisticPatches,
  rollbackOptimisticPatches,
  type OptimisticContext,
} from '@/presentation/hooks/utils/optimistic';
import type { <Entity>, <Input> } from '@/domain';

/**
 * @optimistic yes — patches <Keys>.<list> via prefix match
 * @offline queue-and-replay (Phase 14d)
 */
export function use<Verb><Noun>(): UseMutationResult<<Entity>, Error, <Input>, OptimisticContext> {
  const queryClient = useQueryClient();
  return useMutation<<Entity>, Error, <Input>, OptimisticContext>({
    mutationFn: (input) => <repo>.<method>(input),
    onMutate: async (input) =>
      applyOptimisticPatches({
        queryClient,
        patches: [
          {
            queryKey: <Keys>.<list>,
            exact: true,
            updater: (old) => /* immutable patch */,
          },
        ],
      }),
    onSuccess: () => {
      haptic.success();
      showToast(t().<scope>.toast.<key>);
    },
    onError: (err, _vars, context) => {
      rollbackOptimisticPatches(queryClient, context);
      haptic.error();
      showToast(err.message || t().errors.<key>, 'error');
    },
    onSettled: () => {
      void queryClient.invalidateQueries({ queryKey: <Keys>.<list> });
    },
  });
}

Hard constraints to follow (from AGENTS.md)

  • DATA-2: factory or manual useMutation with applyOptimisticPatches. Always carry a mutationKey
  • DATA-4: errors via captureCriticalError, not Sentry.captureException directly
  • Reviewer checklist: CHK-DATA-1 (optimistic when target row is cached) — see docs/best-practices/review-checklist.md

Post-implementation verification

bash
# 1. Verify changes persisted
git status                                                # should show new file
git diff --name-only HEAD                                # should include the new file

# 2. Type-check
yarn workspace @twomore/app typecheck                    # exit 0

# 3. Tests
yarn workspace @twomore/app jest --runInBand <hook-test-path>  # if you wrote a test

# 4. Architecture contracts
yarn workspace @twomore/app jest --runInBand src/config/__tests__/architecture-archunit.test.ts

What to report

  • Files created/modified (with git status output)
  • Optimistic strategy chosen (and why)
  • Cache keys affected
  • Typecheck + arch test results
  • Any rules in AGENTS.md you decided to flag for review

Markdown remains the source of truth. Run yarn docs:check before handoff.