You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

389 lines
10 KiB
Markdown

# Architecture Improvements for Booking Detail Page
## Current Issues
1. **State Explosion**: 14+ useState hooks make state management complex
2. **Mixed Concerns**: UI state, domain state, and loading states all at same level
3. **No Type Safety for Errors**: String-based errors lose context
4. **Inline Handlers**: Complex async logic in JSX callbacks
5. **No State Machine**: Can end up in invalid states
## Recommended Design Patterns
### 1. **Result Pattern** (Rust-like)
Instead of:
```typescript
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [loading, setLoading] = useState(false);
```
Use:
```typescript
type Result<T, E = Error> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: E };
const [bookingResult, setBookingResult] = useState<Result<BookingData>>({ status: 'idle' });
```
**Benefits**:
- Can't have loading=true and error set simultaneously
- TypeScript enforces checking status before accessing data
- Clear state transitions
### 2. **Custom Hooks for State Logic**
Extract state management into custom hooks:
```typescript
// hooks/useBookingData.ts
export function useBookingData(slotId: string) {
const [result, setResult] = useState<Result<BookingData>>({ status: 'loading' });
const refetch = useCallback(async () => {
setResult({ status: 'loading' });
try {
const data = await fetchBooking(slotId);
setResult({ status: 'success', data });
} catch (error) {
setResult({ status: 'error', error: error as Error });
}
}, [slotId]);
useEffect(() => { refetch(); }, [refetch]);
return { result, refetch };
}
// hooks/useSwapActions.ts
export function useSwapActions(slotId: string, onSuccess: () => void) {
const [actionResult, setActionResult] = useState<Result<void>>({ status: 'idle' });
const approveSwap = useCallback(async (approvalId: number) => {
setActionResult({ status: 'loading' });
try {
await api.approveSwap(slotId, approvalId);
setActionResult({ status: 'success', data: undefined });
onSuccess();
} catch (error) {
setActionResult({ status: 'error', error: error as Error });
}
}, [slotId, onSuccess]);
const cancelSwap = useCallback(async (swapGroupId: string) => {
// Similar pattern
}, [slotId, onSuccess]);
return { actionResult, approveSwap, cancelSwap };
}
```
**Benefits**:
- Testable in isolation
- Reusable across components
- Single responsibility
- Can use React Query or SWR patterns
### 3. **useReducer for Complex State**
For UI state with related updates:
```typescript
type UIState = {
modals: {
picker: boolean;
login: boolean;
matchType: boolean;
punctuality: boolean;
};
selection: {
joinPosition: number | null;
reportSlot: any | null;
};
swaps: {
recorded: [number, number][];
isProcessing: boolean;
forceSwap: boolean;
};
};
type UIAction =
| { type: 'OPEN_MODAL'; modal: keyof UIState['modals'] }
| { type: 'CLOSE_MODAL'; modal: keyof UIState['modals'] }
| { type: 'SET_JOIN_POSITION'; position: number | null }
| { type: 'START_SWAP'; from: number; to: number }
| { type: 'COMPLETE_SWAP' }
| { type: 'RESET_SWAPS' };
function uiReducer(state: UIState, action: UIAction): UIState {
switch (action.type) {
case 'OPEN_MODAL':
return { ...state, modals: { ...state.modals, [action.modal]: true } };
case 'CLOSE_MODAL':
return { ...state, modals: { ...state.modals, [action.modal]: false } };
case 'START_SWAP':
return {
...state,
swaps: {
...state.swaps,
isProcessing: true,
recorded: [...state.swaps.recorded, [action.from, action.to]]
}
};
case 'COMPLETE_SWAP':
return { ...state, swaps: { ...state.swaps, isProcessing: false } };
// ... other cases
default:
return state;
}
}
const [uiState, dispatch] = useReducer(uiReducer, initialUIState);
```
**Benefits**:
- Related state updates happen atomically
- Clear action types (self-documenting)
- Easy to add middleware (logging, analytics)
- Time-travel debugging possible
### 4. **Command Pattern for Actions**
Create command objects for async operations:
```typescript
// commands/SwapCommands.ts
interface Command<T> {
execute(): Promise<T>;
undo?(): Promise<void>;
}
class ApproveSwapCommand implements Command<void> {
constructor(
private slotId: string,
private approvalId: number,
private api: BookingAPI
) {}
async execute(): Promise<void> {
return this.api.approveSwap(this.slotId, this.approvalId);
}
}
class CancelSwapCommand implements Command<void> {
constructor(
private slotId: string,
private swapGroupId: string,
private api: BookingAPI
) {}
async execute(): Promise<void> {
return this.api.cancelSwap(this.slotId, this.swapGroupId);
}
}
// Usage
const command = new ApproveSwapCommand(slotId, approvalId, api);
await executeCommand(command, {
onSuccess: () => setSuccess('Approved!'),
onError: (error) => setError(error.message)
});
```
**Benefits**:
- Testable business logic
- Can add undo/redo
- Easy to add logging, retry logic, etc.
- Separation of concerns
### 5. **Error Boundary with Typed Errors**
```typescript
// types/errors.ts
export class BookingError extends Error {
constructor(
message: string,
public code: string,
public severity: 'fatal' | 'recoverable'
) {
super(message);
this.name = 'BookingError';
}
}
export class NetworkError extends BookingError {
constructor(message: string) {
super(message, 'NETWORK_ERROR', 'recoverable');
}
}
export class BookingNotFoundError extends BookingError {
constructor(slotId: string) {
super(`Booking ${slotId} not found`, 'NOT_FOUND', 'fatal');
}
}
// Component
function handleError(error: unknown) {
if (error instanceof BookingError) {
if (error.severity === 'fatal') {
setFatalError(error);
} else {
setError(error);
}
} else {
setError(new BookingError('Unknown error', 'UNKNOWN', 'recoverable'));
}
}
```
**Benefits**:
- Type-safe error handling
- Clear error severity
- Can add retry strategies per error type
- Better error reporting/logging
### 6. **Service Layer**
Extract API calls into a service:
```typescript
// services/BookingService.ts
export class BookingService {
constructor(private api: ApiClient) {}
async getBooking(slotId: string): Promise<BookingData> {
const response = await this.api.fetch(`/booking/${slotId}`);
if (!response.ok) {
if (response.status === 404) {
throw new BookingNotFoundError(slotId);
}
throw new NetworkError('Failed to load booking');
}
return response.json();
}
async approveSwap(slotId: string, approvalId: number): Promise<void> {
const response = await this.api.fetch(
`/booking/${slotId}/swap-approval/${approvalId}`,
{ method: 'PATCH' }
);
if (!response.ok) {
const data = await response.json();
throw new BookingError(data.message, 'APPROVE_FAILED', 'recoverable');
}
}
async cancelSwap(slotId: string, swapGroupId: string): Promise<void> {
const response = await this.api.fetch(
`/booking/${slotId}/swap-group/${swapGroupId}`,
{ method: 'DELETE' }
);
if (!response.ok) {
const data = await response.json();
throw new BookingError(data.message, 'CANCEL_FAILED', 'recoverable');
}
}
}
// Usage in component
const bookingService = useMemo(() => new BookingService(apiFetch), []);
const { result, refetch } = useBookingData(slotId, bookingService);
```
**Benefits**:
- Testable without React
- Can mock easily
- Reusable across components
- Clear API contract
## Recommended Implementation Plan
### Phase 1: Error Types (Low Risk, High Value)
1. Create typed error classes
2. Update error handling to use typed errors
3. Keep current state structure
### Phase 2: Extract Custom Hooks (Medium Risk, High Value)
1. Create `useBookingData` hook
2. Create `useSwapActions` hook
3. Create `usePositionSwap` hook
4. Move complex logic out of component
### Phase 3: Result Pattern (Medium Risk, Medium Value)
1. Replace loading/error/data trio with Result type
2. Update components to use Result pattern
3. Add type guards for safe access
### Phase 4: UI State Reducer (Low Risk, Medium Value)
1. Move modal states to reducer
2. Move swap states to reducer
3. Keep domain data separate
### Phase 5: Service Layer (Low Risk, High Value)
1. Create BookingService
2. Move all API calls to service
3. Add retry logic, caching, etc. in one place
## Example: Refactored Component Structure
```typescript
export default function BookingDetailPage() {
// Domain data
const bookingService = useBookingService();
const { result: bookingResult, refetch } = useBookingData(slot_id, bookingService);
const { actionResult, approveSwap, cancelSwap } = useSwapActions(slot_id, refetch);
// UI state
const [uiState, dispatch] = useReducer(uiReducer, initialUIState);
// Derived state
const booking = bookingResult.status === 'success' ? bookingResult.data : null;
const error = actionResult.status === 'error' ? actionResult.error : null;
const isLoading = bookingResult.status === 'loading' || actionResult.status === 'loading';
// Render logic
if (bookingResult.status === 'loading') return <Skeleton />;
if (bookingResult.status === 'error' && bookingResult.error.severity === 'fatal') {
return <FullPageError error={bookingResult.error} />;
}
if (!booking) return <NotFound />;
return (
<>
<BookingView booking={booking} />
<SwapGroupsPanel
swapGroups={booking.pending_position_swap_groups}
onApprove={approveSwap}
onCancel={cancelSwap}
/>
<ErrorModal
isOpen={!!error}
onClose={() => dispatch({ type: 'CLEAR_ACTION_ERROR' })}
error={error}
/>
</>
);
}
```
## Tools to Consider
1. **TanStack Query (React Query)**: Handles data fetching, caching, refetching automatically
2. **Zustand**: Lightweight state management if you need global state
3. **XState**: Full state machine implementation for complex flows
4. **Zod**: Runtime type validation for API responses
## Metrics for Success
- **Reduced useState calls**: From 14+ to ~3-5
- **Improved testability**: Can test hooks in isolation
- **Better type safety**: TypeScript catches more errors
- **Clearer separation**: UI state vs domain state vs loading state
- **Easier debugging**: Result types make state explicit
- **Better error handling**: Typed errors with severity levels