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

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

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

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

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