ADR-002: Frontend Logic-Component Split via Dedicated Custom Hooks
Status
Accepted
Date
2026-05-15
Context
In modern React development (React 19), single-file components frequently degrade into monoliths exceeding 800–1200 lines when form state, asynchronous API interactions, optimistic UI handling, accessibility bindings, and modal lifecycles are co-located in the rendering file.
In early iterations of Scripture Habit, components like GroupChat, NewNote, and Dashboard grew bloated and difficult to test because business logic, keyboard listeners, and JSX were intertwined.
Decision
Enforce a strict architectural boundary between Logic (Hooks) and Presentation (Components):
- Presentation Layer (
src/components/):- Components are responsible only for layout, rendering, semantic HTML, and mapping user interactions (clicks, keyboard events) to hook callbacks.
- Files should ideally stay under 200–300 lines of code.
- Components must not make raw
apiClient.postor Firestore calls directly.
- Logic Layer (
src/hooks/andsrc/components/*/hooks/):- All state management (
useState,useReducer), asynchronous side-effects (useEffect), and API orchestrations reside exclusively within custom hooks (e.g.useNoteSubmission,useAIGenerator,useGroupActions,useModalA11y). - Hooks expose clean, declarative interfaces: data, loading/error states, and action dispatchers.
- All state management (
- Subcomponent Decomposition:
- Complex dialogs or composite views are broken down into subcomponents within a dedicated
subcomponents/orcomponents/folder co-located with the feature.
- Complex dialogs or composite views are broken down into subcomponents within a dedicated
Alternatives Considered
1. Global State for Everything (Redux / Heavy Zustand)
- Pros: All state resides in centralized stores.
- Cons: High boilerplate; moves local ephemeral UI state (e.g. dropdown open, input focus) into global scope, complicating cleanup and leading to unnecessary re-renders.
- Why Rejected: Domain hooks provide localized encapsulation with zero boilerplate, while Zustand is preserved for true cross-component globals (e.g. active modal, current user).
2. Co-located Monolithic Components
- Pros: Everything is visible in one file without jumping between directories.
- Cons: High cognitive load, fragile refactoring, difficult to unit test without rendering massive component trees, violating Single Responsibility Principle.
- Why Rejected: Leads to regression-prone code and slows down development.
Consequences
Positive
- Independent Testability: Domain hooks can be thoroughly unit-tested using
@testing-library/react'srenderHook()without mounting complex DOM trees or mocking rendering CSS. - Cognitive Clarity: A developer reading
new-note.tsximmediately grasps the structure without scrolling through hundreds of lines of async fetch handling. - Reusability: Core logic like
useModalA11yoruseApiWarmupOnMountcan be shared across disparate modals without duplicating event listeners.
Negative / Trade-offs
- File Proliferation: A single feature may comprise 4–6 smaller files (main component, hooks, subcomponents) instead of one large file.
- Indirection: Tracing an action requires opening the corresponding hook file.