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:
- Single combined hook — one
useAttendance()returning data + actions - Two hooks —
useAttendance()for reads +useAttendanceMutations()for writes (following TanStack Query'suseQuery/useMutationsplit) - Repository pattern on frontend — inject a repository class, call methods from component
- 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 alluseSqlQuerycalls, 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:
| Concern | Reads Hook | Mutations Hook |
|---|---|---|
| Caching | Reactively subscribes to SQL changes | Not applicable |
| Loading | loading = first-fetch | isPending = in-flight |
| Error | Stale data survives errors | Rollback on error |
| Subscriptions | store.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:
// 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:
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_ABSENTpairs 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.
unmarkAttendancesignature changes (requiresactorId,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
useSqlQueryhook 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
| Alternative | Rejected because |
|---|---|
| Single combined hook | Mixes read lifecycle (subscription, caching) with write lifecycle (optimistic update, rollback). Tests become more complex as they must set up both paths. |
| Repository pattern on frontend | Over-engineered for React components. The StorageBackend interface already provides a repository abstraction. |
| No extraction | Logic remains untested. The component stays at 300+ lines. |
| Raw DELETE in unmarkAttendance | Breaks event sourcing. DELETE-always-wins in PowerSync-style conflict resolution silently loses data. No audit trail. |
References
- Issue #481 — Extract useAttendance() from AttendancePage
- ADR-0023 — Server-Authoritative Model (unrelated but parallel)
- app/CONTEXT.md — AttendanceEvent domain (MARK_PRESENT / MARK_ABSENT)
- TanStack Query useQuery/useMutation docs
- PowerSync conflict resolution
- RxDB incremental operations