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
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
|