Deepen PracticeOrdered learning track

Domain Hooks and Application Hooks

Learn React Hooks, State Management, Component Composition, Context Passing, Component Communications & Orchestration - Part 099

Domain hooks and application hooks as explicit use-case boundaries for React applications, separating view rendering from domain read models, commands, permissions, server state, workflow state, and orchestration.

11 min read2045 words
PrevNext
Lesson 99123 lesson track68–101 Deepen Practice
#react#hooks#state-management#frontend-architecture+3 more

Part 099 — Domain Hooks and Application Hooks

Most React codebases do not become hard to maintain because they lack components. They become hard to maintain because the same component becomes responsible for too many kinds of knowledge:

  • how to render the UI,
  • where data comes from,
  • how server state is cached,
  • how permissions are interpreted,
  • what commands are allowed,
  • how optimistic updates work,
  • how errors are classified,
  • when navigation happens,
  • how workflow transitions move from one state to another.

A component can technically do all of that. A production codebase should not let it.

The goal of this part is to introduce two architectural boundaries:

  1. Domain hooks: hooks that expose a stable API for one domain capability or bounded concern.
  2. Application hooks: hooks that orchestrate a concrete use case by composing domain hooks, server-state hooks, workflow state, permissions, commands, and navigation.

These hooks are not just reusable functions. They are stateful protocol boundaries.


1. The Problem: Components Become Accidental Use-Case Objects

A common enterprise React screen starts like this:

function EnforcementCasePage() {
  const { caseId } = useParams();
  const navigate = useNavigate();
  const queryClient = useQueryClient();
  const user = useCurrentUser();
  const permissions = usePermissions();
  const [selectedTab, setSelectedTab] = useState('overview');
  const [confirmClose, setConfirmClose] = useState(false);
  const [draftNote, setDraftNote] = useState('');

  const caseQuery = useQuery({
    queryKey: ['cases', caseId],
    queryFn: () => fetchCase(caseId!),
  });

  const assignMutation = useMutation({
    mutationFn: assignCase,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['cases', caseId] });
      queryClient.invalidateQueries({ queryKey: ['case-list'] });
    },
  });

  const canAssign = permissions.includes('case.assign') && caseQuery.data?.status === 'OPEN';

  async function handleAssign(officerId: string) {
    if (!canAssign) return;
    await assignMutation.mutateAsync({ caseId: caseId!, officerId });
  }

  if (caseQuery.isPending) return <PageSkeleton />;
  if (caseQuery.isError) return <ErrorState error={caseQuery.error} />;

  return (
    <CaseLayout
      caseData={caseQuery.data}
      selectedTab={selectedTab}
      onTabChange={setSelectedTab}
      canAssign={canAssign}
      onAssign={handleAssign}
      draftNote={draftNote}
      onDraftNoteChange={setDraftNote}
    />
  );
}

The code is not wrong. The problem is that the component now has multiple unrelated reasons to change:

ChangeWhy the component changes
Query key structure changesData fetching is embedded in the page
Permission rule changesPermission logic is embedded in the page
Assign command changesMutation logic is embedded in the page
Cache invalidation changesCache strategy is embedded in the page
Navigation after command changesApplication workflow is embedded in the page
UI layout changesRendering is embedded in the page
Tab state changesLocal view state is embedded in the page

This is a boundary failure.

A page should often compose a use case, but it should not necessarily contain every internal rule of that use case.


2. Mental Model

Think of a React application as several layers of responsibility:

The component should mostly ask:

What should I display?
What user intents can I emit?

The application hook should answer:

What data, permissions, commands, loading states, errors, and workflow transitions exist for this use case?

The domain hook should answer:

What is the stable domain-facing API for this bounded concern?

3. Hook Taxonomy

Not every custom hook deserves to be called a domain or application hook.

Hook TypeExampleResponsibilityShould Know Domain?
Utility hookuseDebouncedValueGeneric reusable behaviorNo
Infrastructure hookuseCurrentRoute, useAnalytics, useLocalStorageStorePlatform/framework integrationNo or minimal
Server-state hookuseCaseQuery, useAssignCaseMutationQuery/mutation/cache adapterThin domain awareness
Domain hookuseCasePermissions, useCaseAssignment, useCaseTimelineDomain capability/read model/commandsYes
Application hookuseCaseDetailPage, useCaseResolutionWorkflowUse-case orchestrationYes, plus workflow
View hookuseTabs, useDisclosureUI behavior onlyNo

The important distinction is not naming. It is responsibility.


4. Domain Hook

A domain hook is a hook that exposes a domain capability using a stable interface.

It may compose:

  • server-state query hooks,
  • mutation hooks,
  • external stores,
  • permission capabilities,
  • context providers,
  • reducers,
  • feature flags,
  • analytics commands,
  • domain-specific selectors.

But it should expose a small surface:

const assignment = useCaseAssignment(caseId);

assignment.assignee;
assignment.canAssign;
assignment.assign(officerId);
assignment.isAssigning;
assignment.assignError;

The component should not need to know:

  • which query key is invalidated,
  • how the mutation payload is shaped internally,
  • whether optimistic update is used,
  • whether assignment depends on case status, role, tenant, or feature flag,
  • how errors are normalized,
  • how audit events are emitted.

That belongs behind the domain hook.


5. Application Hook

An application hook orchestrates a concrete use case or screen workflow.

Example:

const page = useCaseDetailPage({ caseId });

It might expose:

type CaseDetailPageModel = {
  status: 'loading' | 'not-found' | 'forbidden' | 'ready' | 'error';
  case: CaseViewModel | null;
  tabs: {
    selected: CaseTab;
    select(tab: CaseTab): void;
  };
  assignment: {
    assignee: OfficerSummary | null;
    canAssign: boolean;
    assign(officerId: string): Promise<void>;
    isAssigning: boolean;
  };
  resolution: {
    canResolve: boolean;
    start(): void;
    cancel(): void;
    submit(input: ResolutionInput): Promise<void>;
    state: ResolutionWorkflowState;
  };
};

The page component then becomes an adapter from read model to JSX:

function CaseDetailPage() {
  const { caseId } = useRequiredParams(['caseId']);
  const page = useCaseDetailPage({ caseId });

  if (page.status === 'loading') return <CasePageSkeleton />;
  if (page.status === 'not-found') return <NotFound />;
  if (page.status === 'forbidden') return <Forbidden />;
  if (page.status === 'error') return <ErrorState error={page.error} />;

  return <CaseDetailView model={page} />;
}

This is not about making fewer lines. It is about creating a boundary where use-case behavior can evolve without turning the render component into a procedural script.


6. A Domain Hook Should Expose a Read Model and Commands

Avoid returning raw infrastructure objects directly from a domain hook.

Weak API:

function useCaseAssignment(caseId: string) {
  const query = useQuery(...);
  const mutation = useMutation(...);
  return { query, mutation };
}

This leaks implementation. Every caller now knows TanStack Query structure, mutation lifecycle, and cache behavior.

Better API:

type UseCaseAssignmentResult = {
  assignee: OfficerSummary | null;
  canAssign: boolean;
  isAssigning: boolean;
  assignError: DomainError | null;
  assign(officerId: string): Promise<AssignCaseResult>;
};

function useCaseAssignment(caseId: CaseId): UseCaseAssignmentResult {
  const queryClient = useQueryClient();
  const caseQuery = useCaseQuery(caseId);
  const permission = usePermission('case.assign');

  const mutation = useMutation({
    mutationFn: assignCase,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: caseKeys.detail(caseId) });
      queryClient.invalidateQueries({ queryKey: caseKeys.lists() });
    },
  });

  const canAssign =
    permission.allowed &&
    caseQuery.data?.status === 'OPEN' &&
    !mutation.isPending;

  const assign = useCallback(
    async (officerId: string) => {
      if (!canAssign) {
        return { ok: false as const, reason: 'not_allowed' as const };
      }

      await mutation.mutateAsync({ caseId, officerId });
      return { ok: true as const };
    },
    [caseId, canAssign, mutation]
  );

  return {
    assignee: caseQuery.data?.assignee ?? null,
    canAssign,
    isAssigning: mutation.isPending,
    assignError: mutation.error ? normalizeDomainError(mutation.error) : null,
    assign,
  };
}

The caller receives domain semantics, not infrastructure leakage.


7. Command Contract

A domain/application hook command should answer these questions:

What intent does this command represent?
Is it allowed now?
What input does it accept?
What does it return?
Can it be called multiple times?
Is it idempotent?
Can it be cancelled?
Does it update cache?
Does it navigate?
Does it emit analytics/audit?
What error shape does it expose?

Weak command API:

onClick={() => mutation.mutate({ id })}

Better command API:

const resolution = useCaseResolutionWorkflow(caseId);

<Button
  disabled={!resolution.canSubmit}
  onClick={() => resolution.submitResolution({ outcome: 'APPROVED' })}
>
  Approve
</Button>

The button does not know mutation details. It knows user intent.


8. Read Model Contract

A hook should return data in the shape the caller actually needs.

Do not force view components to understand raw API response structures:

// Weak: view must understand backend response shape.
const { data } = useCaseQuery(caseId);
return <CaseHeader statusCode={data.workflow.current_state.code} />;

Prefer a read model:

type CaseHeaderModel = {
  title: string;
  statusLabel: string;
  riskLevel: 'low' | 'medium' | 'high';
  assigneeLabel: string;
  openedAtLabel: string;
};

function useCaseHeader(caseId: CaseId): CaseHeaderModelQuery {
  const query = useCaseQuery(caseId);

  return {
    status: query.status,
    error: query.error,
    data: query.data
      ? {
          title: query.data.referenceNumber,
          statusLabel: formatCaseStatus(query.data.status),
          riskLevel: classifyRisk(query.data.riskScore),
          assigneeLabel: query.data.assignee?.displayName ?? 'Unassigned',
          openedAtLabel: formatDate(query.data.openedAt),
        }
      : null,
  };
}

A read model is not just mapping. It is an architectural boundary between API shape and UI shape.


9. The Dependency Direction Rule

Domain/application hooks should depend inward toward infrastructure adapters and outward toward the view only through stable return contracts.

Bad direction:

// api/cases.ts
import { toast } from '@/components/toast';
import { navigate } from '@/router';

export async function assignCase(input: AssignInput) {
  const result = await http.post('/cases/assign', input);
  toast.success('Assigned');
  navigate('/cases');
  return result;
}

The API client now knows UI and navigation. That breaks layering.

Better:

function useAssignCaseCommand(caseId: CaseId) {
  const toast = useToast();
  const navigate = useNavigate();
  const mutation = useAssignCaseMutation();

  return async function assign(input: AssignInput) {
    await mutation.mutateAsync(input);
    toast.success('Case assigned');
    navigate(`/cases/${caseId}`);
  };
}

Application hooks are allowed to know UI-adjacent capabilities like toast/navigation because they are use-case orchestration boundaries.


10. Domain Hooks Should Not Be Too Generic

A common mistake is to create hooks like this:

function useEntityManager<T>() {
  // search, create, update, delete, validate, export, assign, approve...
}

This usually becomes a mini framework. It hides domain semantics behind generic names.

Prefer domain vocabulary:

useCaseSearch()
useCaseAssignment(caseId)
useCaseTimeline(caseId)
useCaseResolutionWorkflow(caseId)
useOfficerAvailability(regionId)

Generic helper hooks are useful, but the public hook that a screen consumes should speak domain language.


11. Domain Hook Example: useCaseSearch

A case search screen often has multiple state categories:

  • URL state for committed filters,
  • local state for draft filters,
  • server state for results,
  • persistent state for table density/columns,
  • command state for export,
  • permission state for actions.

A domain hook can own the search read model.

type CaseSearchParams = {
  q?: string;
  status?: CaseStatus[];
  assigneeId?: string;
  page: number;
  pageSize: number;
  sort: 'openedAt.desc' | 'risk.desc' | 'status.asc';
};

type CaseSearchResult = {
  rows: CaseSearchRow[];
  total: number;
  page: number;
  pageSize: number;
  isLoading: boolean;
  isFetching: boolean;
  error: DomainError | null;
  canExport: boolean;
  refetch(): void;
  exportCsv(): Promise<void>;
};

function useCaseSearch(params: CaseSearchParams): CaseSearchResult {
  const permission = usePermission('case.export');
  const query = useQuery({
    queryKey: caseKeys.search(params),
    queryFn: () => searchCases(params),
    staleTime: 30_000,
  });

  const exportMutation = useMutation({
    mutationFn: () => exportCases(params),
  });

  return {
    rows: query.data?.items.map(toCaseSearchRow) ?? [],
    total: query.data?.total ?? 0,
    page: params.page,
    pageSize: params.pageSize,
    isLoading: query.isPending,
    isFetching: query.isFetching,
    error: query.error ? normalizeDomainError(query.error) : null,
    canExport: permission.allowed && (query.data?.total ?? 0) > 0,
    refetch: query.refetch,
    exportCsv: async () => {
      if (!permission.allowed) return;
      await exportMutation.mutateAsync();
    },
  };
}

Notice the responsibilities:

  • the hook accepts committed params,
  • it owns query key and query function,
  • it maps API data to row read model,
  • it exposes export as command,
  • it does not render table UI,
  • it does not own draft filter UI unless that is the chosen application boundary.

12. Application Hook Example: useCaseSearchPage

The page hook composes URL state, draft filters, query state, table state, and commands.

type CaseSearchPageModel = {
  filters: {
    draft: CaseFilterDraft;
    setDraft(next: CaseFilterDraft): void;
    apply(): void;
    reset(): void;
  };
  table: {
    rows: CaseSearchRow[];
    total: number;
    page: number;
    setPage(page: number): void;
    sort: CaseSearchParams['sort'];
    setSort(sort: CaseSearchParams['sort']): void;
  };
  status: {
    isInitialLoading: boolean;
    isRefreshing: boolean;
    error: DomainError | null;
  };
  commands: {
    canExport: boolean;
    exportCsv(): Promise<void>;
  };
};

function useCaseSearchPage(): CaseSearchPageModel {
  const [searchParams, setSearchParams] = useSearchParams();
  const committed = parseCaseSearchParams(searchParams);
  const [draft, setDraft] = useState(() => toDraft(committed));

  const search = useCaseSearch(committed);

  const apply = useCallback(() => {
    setSearchParams(serializeCaseSearchParams(fromDraft(draft)));
  }, [draft, setSearchParams]);

  const reset = useCallback(() => {
    const next = defaultCaseSearchParams();
    setDraft(toDraft(next));
    setSearchParams(serializeCaseSearchParams(next));
  }, [setSearchParams]);

  return {
    filters: {
      draft,
      setDraft,
      apply,
      reset,
    },
    table: {
      rows: search.rows,
      total: search.total,
      page: committed.page,
      setPage: (page) =>
        setSearchParams(serializeCaseSearchParams({ ...committed, page })),
      sort: committed.sort,
      setSort: (sort) =>
        setSearchParams(serializeCaseSearchParams({ ...committed, sort, page: 1 })),
    },
    status: {
      isInitialLoading: search.isLoading,
      isRefreshing: search.isFetching && !search.isLoading,
      error: search.error,
    },
    commands: {
      canExport: search.canExport,
      exportCsv: search.exportCsv,
    },
  };
}

The page component can now be simple:

function CaseSearchPage() {
  const page = useCaseSearchPage();

  return (
    <CaseSearchLayout
      filters={page.filters}
      table={page.table}
      status={page.status}
      commands={page.commands}
    />
  );
}

This does not make the logic disappear. It makes the logic inspectable, testable, and movable.


13. Application Hooks and Workflow State

When a use case has real workflow, do not hide it inside many booleans.

Bad:

const [isDialogOpen, setDialogOpen] = useState(false);
const [isSubmitting, setSubmitting] = useState(false);
const [isSuccess, setSuccess] = useState(false);
const [error, setError] = useState<Error | null>(null);

Better:

type ResolutionState =
  | { tag: 'idle' }
  | { tag: 'editing'; draft: ResolutionDraft }
  | { tag: 'confirming'; draft: ResolutionDraft }
  | { tag: 'submitting'; draft: ResolutionDraft; requestId: string }
  | { tag: 'succeeded'; resultId: string }
  | { tag: 'failed'; draft: ResolutionDraft; error: DomainError };

The application hook can expose workflow commands:

function useCaseResolutionWorkflow(caseId: CaseId) {
  const [state, dispatch] = useReducer(resolutionReducer, { tag: 'idle' });
  const mutation = useResolveCaseMutation(caseId);

  const start = () => dispatch({ type: 'started' });
  const cancel = () => dispatch({ type: 'cancelled' });
  const edit = (patch: Partial<ResolutionDraft>) =>
    dispatch({ type: 'edited', patch });

  const submit = async () => {
    if (state.tag !== 'confirming') return;

    const requestId = crypto.randomUUID();
    dispatch({ type: 'submitted', requestId });

    try {
      const result = await mutation.mutateAsync({ caseId, draft: state.draft });
      dispatch({ type: 'succeeded', requestId, resultId: result.id });
    } catch (error) {
      dispatch({
        type: 'failed',
        requestId,
        error: normalizeDomainError(error),
      });
    }
  };

  return {
    state,
    start,
    cancel,
    edit,
    submit,
    canSubmit: state.tag === 'confirming' && !mutation.isPending,
  };
}

A workflow hook should make illegal states hard to represent.


14. Application Hook Output Shape

A production application hook should not return a bag of random values.

Weak:

return {
  data,
  isLoading,
  isFetching,
  error,
  selectedTab,
  setSelectedTab,
  assign,
  resolve,
  close,
  canEdit,
  canDelete,
  columns,
  setColumns,
};

Better group by responsibility:

return {
  pageStatus,
  header,
  tabs,
  assignment,
  resolution,
  notes,
  permissions,
  commands,
};

A grouped shape communicates architecture.


15. Permission Handling

Do not scatter permission checks across components.

Bad:

{user.roles.includes('admin') && case.status !== 'CLOSED' && (
  <Button onClick={handleAssign}>Assign</Button>
)}

Better:

const assignment = useCaseAssignment(caseId);

<Button disabled={!assignment.canAssign} onClick={() => assignment.assign(officerId)}>
  Assign
</Button>

The permission rule may depend on:

  • role,
  • tenant,
  • case state,
  • assignment state,
  • feature flag,
  • server policy,
  • record lock,
  • approval stage,
  • jurisdiction.

A component should not encode that rule unless it is purely visual.


16. Error Normalization

Infrastructure errors should not leak into UI components as arbitrary unknown values.

type DomainError =
  | { tag: 'network'; retryable: true; message: string }
  | { tag: 'validation'; fields: Record<string, string> }
  | { tag: 'forbidden'; requiredPermission?: string }
  | { tag: 'conflict'; currentVersion?: string }
  | { tag: 'not_found'; resource: string }
  | { tag: 'unknown'; message: string };

A domain/application hook is a good place to normalize errors:

function useResolveCaseMutation(caseId: CaseId) {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: resolveCase,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: caseKeys.detail(caseId) });
      queryClient.invalidateQueries({ queryKey: caseKeys.lists() });
    },
    throwOnError: false,
  });
}

function useCaseResolution(caseId: CaseId) {
  const mutation = useResolveCaseMutation(caseId);

  return {
    submit: mutation.mutateAsync,
    isSubmitting: mutation.isPending,
    error: mutation.error ? normalizeDomainError(mutation.error) : null,
  };
}

The view renders domain error states, not transport details.


17. Dependency Injection in Hooks

Hooks often become hard to test because they import everything directly:

function useCaseResolution(caseId: CaseId) {
  const mutation = useMutation({ mutationFn: resolveCase });
  const analytics = realAnalytics;
  const clock = realClock;
}

For critical workflows, inject capabilities through context or explicit options:

type CaseResolutionDeps = {
  clock: Clock;
  idGenerator: IdGenerator;
  analytics: Analytics;
};

const CaseResolutionDepsContext = createContext<CaseResolutionDeps | null>(null);

function useCaseResolutionDeps() {
  const deps = useContext(CaseResolutionDepsContext);
  if (!deps) throw new Error('Missing CaseResolutionDepsProvider');
  return deps;
}

function useCaseResolutionWorkflow(caseId: CaseId) {
  const { clock, idGenerator, analytics } = useCaseResolutionDeps();
  // ...
}

This gives you seams for tests without converting the app into dependency-injection theater.

Use injection for things that materially affect behavior:

  • current time,
  • ID generation,
  • analytics/audit emission,
  • permission provider,
  • API adapter,
  • feature flag reader,
  • router/navigation port.

Do not inject everything by default.


18. Application Hook as a Composition Root

A page hook may become a composition root for several smaller hooks.

This is acceptable if the hook exposes a stable, grouped model.

It becomes a problem if it turns into a thousand-line procedural script.

When the page hook grows too large, split by capability:

useCaseDetailPage
  useCaseHeaderModel
  useCaseTimelinePanel
  useCaseAssignmentPanel
  useCaseResolutionPanel
  useCaseAuditPanel

The page hook remains a wiring boundary, not a dumping ground.


19. Domain Hook vs Query Hook

A query hook is not necessarily a domain hook.

function useCaseQuery(caseId: CaseId) {
  return useQuery({
    queryKey: caseKeys.detail(caseId),
    queryFn: () => getCase(caseId),
  });
}

This is a server-state adapter.

A domain hook interprets it:

function useCaseClosureEligibility(caseId: CaseId) {
  const caseQuery = useCaseQuery(caseId);
  const permission = usePermission('case.close');

  if (!caseQuery.data) {
    return { status: 'unknown' as const, reason: 'loading' as const };
  }

  if (!permission.allowed) {
    return { status: 'blocked' as const, reason: 'permission' as const };
  }

  if (caseQuery.data.openFindings > 0) {
    return { status: 'blocked' as const, reason: 'open_findings' as const };
  }

  return { status: 'allowed' as const };
}

Keep this distinction clear:

Query hook: how to fetch/cache server data.
Domain hook: what this data means for a domain capability.
Application hook: how capabilities combine into a user journey.

20. Domain Hook vs Context

Context provides scoped dependency passing. It does not automatically make a good domain hook.

Weak:

const CaseContext = createContext<any>(null);

Better:

function CaseProvider({ caseId, children }: PropsWithChildren<{ caseId: CaseId }>) {
  const caseQuery = useCaseQuery(caseId);
  const permissions = useCasePermissions(caseId);

  const value = useMemo(
    () => ({ caseId, caseQuery, permissions }),
    [caseId, caseQuery, permissions]
  );

  return <CaseContext.Provider value={value}>{children}</CaseContext.Provider>;
}

function useCaseAssignmentFromContext() {
  const { caseId } = useRequiredCaseContext();
  return useCaseAssignment(caseId);
}

Context can provide scope. Domain hooks should still define behavior.


21. View Components Should Stay Boring

Once an application hook exists, view components should become almost boring.

function CaseAssignmentPanel({ model }: { model: CaseAssignmentModel }) {
  return (
    <Panel title="Assignment">
      <Text>{model.assigneeLabel}</Text>
      <Button disabled={!model.canAssign} loading={model.isAssigning} onClick={model.openAssignDialog}>
        Assign
      </Button>
      {model.error && <InlineError error={model.error} />}
    </Panel>
  );
}

A boring component is not a weak component. It is a component with a narrow job.


22. Avoid Raw Setter Leakage

Raw setters leak state representation.

Weak:

return {
  status,
  setStatus,
  selectedOfficerId,
  setSelectedOfficerId,
};

Better:

return {
  state,
  selectOfficer(officerId: OfficerId) {
    dispatch({ type: 'officer_selected', officerId });
  },
  clearOfficer() {
    dispatch({ type: 'officer_cleared' });
  },
};

Raw setters are acceptable for pure local view state. They are usually wrong for domain or workflow state.


23. Avoid Boolean Soup in Hook Output

Bad:

return {
  isLoading,
  isSaving,
  isValid,
  isDirty,
  isSubmitted,
  isErrored,
  isRetrying,
};

Better:

type SaveState =
  | { tag: 'idle' }
  | { tag: 'dirty'; canSave: boolean }
  | { tag: 'saving'; requestId: string }
  | { tag: 'saved'; savedAt: string }
  | { tag: 'failed'; error: DomainError; canRetry: boolean };

Booleans multiply possible states. Tagged unions constrain them.


24. Return Stable Commands Deliberately

A hook does not always need to stabilize every returned function, especially in React Compiler-compatible code. But public hook APIs often benefit from stable command identities when passed deep into memoized children.

function useCaseCommands(caseId: CaseId) {
  const closeMutation = useCloseCaseMutation(caseId);

  const closeCase = useCallback(
    async (reason: CloseReason) => {
      await closeMutation.mutateAsync({ caseId, reason });
    },
    [caseId, closeMutation]
  );

  return { closeCase };
}

Use stabilization intentionally:

  • when returned commands are passed to memoized child components,
  • when commands are dependencies of other hooks,
  • when the command identity is part of a provider value,
  • when resubscription would be expensive.

Do not use useCallback as a ritual.


25. File and Folder Placement

A practical structure:

src/
  shared/
    api/
    lib/
    ui/
  entities/
    case/
      api/
        caseApi.ts
        caseKeys.ts
      model/
        caseTypes.ts
        caseSelectors.ts
      hooks/
        useCaseQuery.ts
      ui/
        CaseStatusBadge.tsx
  features/
    case-assignment/
      api/
        assignCase.ts
      model/
        assignmentTypes.ts
      hooks/
        useCaseAssignment.ts
      ui/
        CaseAssignmentPanel.tsx
  pages/
    case-detail/
      hooks/
        useCaseDetailPage.ts
      ui/
        CaseDetailPage.tsx
        CaseDetailView.tsx

Rules:

entity hook = entity data/read capability
feature hook = user action/capability
page hook = use-case orchestration
shared hook = generic reusable primitive

26. Testing Domain Hooks

Test domain hooks at the contract level.

test('assignment is disabled when case is closed', async () => {
  const server = setupMockServer({
    case: { id: 'case-1', status: 'CLOSED', assignee: null },
    permissions: ['case.assign'],
  });

  const { result } = renderHook(() => useCaseAssignment('case-1'), {
    wrapper: createTestProviders({ server }),
  });

  await waitFor(() => {
    expect(result.current.canAssign).toBe(false);
  });
});

Do not only test that a query was called. Test the domain behavior.

Good tests ask:

What does the hook expose when data is loading?
What does it expose when permission is missing?
What happens on command success?
What happens on command conflict?
Does it invalidate the correct read models?
Does it prevent duplicate/illegal commands?
Does it preserve draft state correctly?

27. Testing Application Hooks

Application hook tests should look like user journey tests without rendering the full visual tree.

test('case resolution workflow succeeds after confirm and submit', async () => {
  const { result } = renderHook(() => useCaseResolutionWorkflow('case-1'), {
    wrapper: createTestProviders({
      api: fakeApi({ resolveCase: async () => ({ id: 'resolution-1' }) }),
    }),
  });

  act(() => result.current.start());
  act(() => result.current.edit({ outcome: 'APPROVED' }));
  act(() => result.current.confirm());

  await act(async () => {
    await result.current.submit();
  });

  expect(result.current.state.tag).toBe('succeeded');
});

This catches workflow bugs before UI details obscure them.


28. Observability

Critical application hooks should emit observable transition events.

type UiTransitionEvent = {
  feature: 'case-resolution';
  from: string;
  to: string;
  event: string;
  caseId: string;
  requestId?: string;
};

Emit at the command/workflow boundary, not randomly in view components.

analytics.track('case_resolution_submitted', {
  caseId,
  requestId,
  outcome: state.draft.outcome,
});

For regulated workflows, this boundary also helps auditability:

  • the UI intent is explicit,
  • permission decision is explicit,
  • transition result is explicit,
  • server response mapping is explicit.

29. Decision Matrix

SituationPrefer
Pure reusable behaviorUtility hook
Browser/framework integrationInfrastructure hook
Server resource fetch/cacheQuery hook
Domain read model or capabilityDomain hook
Multi-step screen workflowApplication hook
State shared by a subtreeProvider + domain hooks
High-frequency shared client stateExternal store + selector hook
Complex legal transition graphState machine/application hook
Rendering primitive behaviorHeadless/view hook

30. Anti-Patterns

30.1 The God Application Hook

function useEverythingPage() {
  // 1200 lines of unrelated behavior
}

Split by capability.

30.2 The Transparent Wrapper Hook

function useCases() {
  return useQuery(...);
}

This may be fine as a query hook, but it is not a meaningful domain boundary unless it adds domain semantics.

30.3 The UI-Aware Domain Hook

function useCaseAssignment() {
  return {
    renderAssignButton: () => <Button>Assign</Button>,
  };
}

Domain hooks should not return JSX. Return model and commands.

30.4 The Raw Global Service Hook

function useCaseStuff() {
  return window.caseService;
}

This bypasses React state, lifecycle, and testability.

30.5 Permission Rule Duplication

canAssign = role === 'ADMIN' && status === 'OPEN'

Repeated in five components, this becomes inconsistent. Centralize domain capability rules.

30.6 Unbounded Application Hook Return

Returning 40 top-level values makes the hook as hard to understand as the original component.

Group by model area.


31. Refactor Recipe

When a page component is too smart:

1. Identify server-state queries.
2. Extract query hooks and query key factories.
3. Identify domain decisions: canEdit, canAssign, canClose, statusLabel.
4. Extract domain hooks around those decisions.
5. Identify workflow states: editing, confirming, submitting, failed.
6. Move workflow to reducer/state machine inside application hook.
7. Replace raw callbacks with command methods.
8. Shape a read model for view components.
9. Test the hook contracts.
10. Keep the page component as a composition adapter.

32. Engineering Review Checklist

Ask these during review:

Is the component rendering or orchestrating?
Does this hook expose domain semantics or infrastructure internals?
Does the hook return raw setters for domain/workflow state?
Are commands named by user intent?
Are query keys and invalidation hidden behind a domain boundary?
Are permissions centralized?
Are errors normalized?
Can the hook be tested without rendering the full page?
Does the output shape group related responsibilities?
Can this hook be reused safely, or is it too page-specific?
Does the hook hide navigation/toast/analytics in a surprising place?
Does it preserve React purity and effect boundaries?

33. Summary

Domain hooks and application hooks are not mandatory ceremony. They are useful when a React screen has enough behavioral complexity that components start to mix rendering with domain decisions and orchestration.

The core rule:

Components render.
Domain hooks explain what the domain allows and means.
Application hooks coordinate a user journey.
Infrastructure hooks adapt external systems.

A strong React architecture does not try to remove complexity. It puts complexity behind the right boundary.


References

Lesson Recap

You just completed lesson 99 in deepen practice. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.

Continue The Track

Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.