Bart DorseyProgramming tutorials, cheatsheets, and guides.

Frontend API Layer

Project Structure

The frontend is built with React 19 and TypeScript, organized into feature-based folders:

src/
├── api/
│   └── photos-api.ts           # All network calls
├── features/
│   ├── photos/                 # Photo display components
│   └── upload/
│       ├── SimpleUploadForm.tsx
│       └── UploadForm.tsx
└── App.tsx                     # Routing

Keeping all API calls in one file means components never call fetch directly — they only deal with typed results.


The Result Pattern

Rather than throwing exceptions on failure, every API function returns an ApiResult<T> — a discriminated union that forces callers to handle both outcomes:

frontend/src/api/photos-api.ts#L12-L18 on GitHub

// src/api/photos-api.ts

export type ApiResult<T> =
  | {
      success: true;
      data: T;
    }
  | {
      success: false;
      error: string;
    };

TypeScript narrows the type automatically when you check result.success:

const result = await uploadPhoto(formData);

if (result.success) {
  console.log(result.data); // TypeScript knows this is PhotoResponse
} else {
  console.log(result.error); // TypeScript knows this is string
}

Accessing result.data without checking result.success first is a type error — TypeScript enforces correct error handling at compile time.


Response Types

frontend/src/api/photos-api.ts#L4-L9 on GitHub

export type PhotoResponse = {
  id: number;
  photo_url: string;
  title?: string;
  description?: string;
};

This mirrors the PhotoResponse Pydantic model on the backend.


API Functions

frontend/src/api/photos-api.ts#L23-L86 on GitHub

const baseUrl = import.meta.env.VITE_API_URL ?? "http://localhost:8000";

export async function getAllPhotos(): Promise<ApiResult<PhotoResponse[]>> {
  try {
    const response = await fetch(`${baseUrl}/api/photos`);
    if (!response.ok) {
      return { success: false, error: "Couldn't fetch photos" };
    }
    const photos = await response.json();
    return { success: true, data: photos };
  } catch (e) {
    return {
      success: false,
      error: e instanceof Error ? e.message : "Unknown error occurred",
    };
  }
}

export async function uploadPhoto(
  formData: FormData,
): Promise<ApiResult<PhotoResponse>> {
  try {
    const response = await fetch(`${baseUrl}/api/photos`, {
      method: "POST",
      body: formData,
    });

    if (!response.ok) {
      return { success: false, error: "Unable to upload image" };
    }

    const photo = await response.json();
    return { success: true, data: photo };
  } catch (e) {
    return {
      success: false,
      error: e instanceof Error ? e.message : "Unknown error occurred",
    };
  }
}

Why this approach?

Uploading with FormData

The uploadPhoto function passes FormData directly as the request body. The browser sets the correct Content-Type: multipart/form-data header automatically — you should not set it manually, as doing so omits the boundary string that the server needs to parse the body.