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.
10 KiB
10 KiB
Architecture Improvements for Booking Detail Page
Current Issues
- State Explosion: 14+ useState hooks make state management complex
- Mixed Concerns: UI state, domain state, and loading states all at same level
- No Type Safety for Errors: String-based errors lose context
- Inline Handlers: Complex async logic in JSX callbacks
- No State Machine: Can end up in invalid states
Recommended Design Patterns
1. Result Pattern (Rust-like)
Instead of:
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [loading, setLoading] = useState(false);
Use:
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:
// 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:
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:
// 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
// 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:
// 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)
- Create typed error classes
- Update error handling to use typed errors
- Keep current state structure
Phase 2: Extract Custom Hooks (Medium Risk, High Value)
- Create
useBookingDatahook - Create
useSwapActionshook - Create
usePositionSwaphook - Move complex logic out of component
Phase 3: Result Pattern (Medium Risk, Medium Value)
- Replace loading/error/data trio with Result type
- Update components to use Result pattern
- Add type guards for safe access
Phase 4: UI State Reducer (Low Risk, Medium Value)
- Move modal states to reducer
- Move swap states to reducer
- Keep domain data separate
Phase 5: Service Layer (Low Risk, High Value)
- Create BookingService
- Move all API calls to service
- Add retry logic, caching, etc. in one place
Example: Refactored Component Structure
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
- TanStack Query (React Query): Handles data fetching, caching, refetching automatically
- Zustand: Lightweight state management if you need global state
- XState: Full state machine implementation for complex flows
- 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