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 hooksuseAttendance() 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.