Skip to content

ADR-0027: Two-Hook Read/Mutation Pattern for UI Data Access ​

  • Status: Accepted
  • Date: 2026-07-23

Context ​

AttendancePage.tsx (320 lines) mixed raw SQL queries, derived absence state (absent vs. unmarked), optimistic updates, role checks, and offline coordination with rendering logic. A significant portion (~190 lines) of business logic was untested — it existed only as inline useMemo/useCallback/useEffect inside the component.

The same pattern was emerging: other pages (Students, Reports) also mixed data access with rendering. Without a convention, each page would invent its own structure.

Several alternatives existed for how to structure the extracted logic:

  1. Single combined hook — one useAttendance() returning data + actions
  2. Two hooks — useAttendance() for reads + useAttendanceMutations() for writes (following TanStack Query's useQuery/useMutation split)
  3. Repository pattern on frontend — inject a repository class, call methods from component
  4. No extraction — keep logic in component, add more tests

Additionally, the unmarkAttendance path had a design divergence: it deleted the presence event directly via raw SQL instead of writing a MARK_ABSENT event through the EventWriter. This broke the event-sourcing invariant (events are append-only) and created an inconsistent write path.

Decision ​

1. Two-Hook Pattern ​

Adopt the two-hook pattern for all UI data access:

  • use<Domain>(sessionId?) — reads hook. Encapsulates all useSqlQuery calls, derives computed state (markedStudents, getMarkState, aggregation), returns { data, loading, error } + derived-domain state. No mutation logic.

  • use<Domain>Mutations() — mutations hook. Encapsulates all write operations, returns action functions + isPending. Thin wrapper over domain services (e.g., attendanceService).

Rationale: Reads and mutations have fundamentally different lifecycle concerns:

ConcernReads HookMutations Hook
CachingReactively subscribes to SQL changesNot applicable
Loadingloading = first-fetchisPending = in-flight
ErrorStale data survives errorsRollback on error
Subscriptionsstore.onChange(table, callback)Not applicable

This matches the industry convention established by TanStack Query (useQuery / useMutation), Apollo (useQuery / useMutation), and React's own documentation pattern of "extract stateful logic, not effects."

2. MARK_ABSENT Event (Not DELETE) ​

unmarkAttendance converged onto the same EventWriter path by writing a MARK_ABSENT event instead of deleting the MARK_PRESENT event:

typescript
// Before (raw DELETE — breaks event sourcing):
DELETE FROM attendance_events WHERE studentId = ? AND classSessionId = ?

// After (twin MARK_ABSENT event — preserves audit trail):
eventWriter.writeAttendance({ ..., actionType: "MARK_ABSENT" })

Read-side derivation: the hook determines state by looking at the latest event per student+session:

typescript
const getMarkState = (studentId: string): StudentMarkState => {
  const latest = eventsByKey.get(studentId) as latest-event;
  if (latest?.actionType === "MARK_PRESENT" && !latest.isConflictLoser) return "marked";
  if (latest?.actionType === "MARK_ABSENT" && !latest.isConflictLoser) return "absent";
  return "unmarked";
};

This follows the event-sourcing invariant of append-only logs and aligns with PowerSync's field-level last-write-wins strategy and RxDB's incrementalModify() pattern.

Consequences ​

Positive ​

  • Testability. Reads hook tested with createTestStore() (MemoryVFS) — no mocking. Mutations hook tested with mocked service. Component logic drops to rendering only.
  • Locality. Changing a SQL query affects only the reads hook. Changing write behavior affects only the mutations hook or the service layer.
  • New entity follows pattern. Every new page follows the same shape: use<X>() + use<X>Mutations() + thin component.
  • Consistent write path. Every attendance write goes through eventWriter.writeAttendance(). No more dual-path divergence.
  • Audit trail preserved. MARK_PRESENT + MARK_ABSENT pairs form a complete log; raw DELETE destroyed information.

Negative ​

  • Two imports instead of one. Pages that need both reads and mutations import two hooks. Minor ergonomic cost.
  • Breaking change. unmarkAttendance signature changes (requires actorId, actorRole). Updates needed at call site.
  • Overhead for trivial pages. Very simple pages may not need the split. Judgment required; this is not a blanket rule.

Neutrals ​

  • The pattern is opt-in. Existing pages can migrate incrementally.
  • The useSqlQuery hook remains the primitive; domain hooks build on top of it.
  • ADR-0023 (Server-Authoritative Model) is unaffected — this is a frontend-only concern.

Alternatives Considered ​

AlternativeRejected because
Single combined hookMixes read lifecycle (subscription, caching) with write lifecycle (optimistic update, rollback). Tests become more complex as they must set up both paths.
Repository pattern on frontendOver-engineered for React components. The StorageBackend interface already provides a repository abstraction.
No extractionLogic remains untested. The component stays at 300+ lines.
Raw DELETE in unmarkAttendanceBreaks event sourcing. DELETE-always-wins in PowerSync-style conflict resolution silently loses data. No audit trail.

References ​

Distribuído sob licença MIT.