API Integration - Frontend
Frontend API integration using OpenAPI-generated TypeScript clients ensures type safety, reduces manual coding, and keeps frontend and backend contracts in sync.
Overview
Generate TypeScript clients from OpenAPI specifications to:
- Ensure compile-time type safety
- Automatically update when APIs change
- Reduce manual API client code
- Catch breaking changes early
- Maintain contract compliance
Schema-First Frontend Development
Generate TypeScript types and API clients from OpenAPI specs before writing frontend code. This ensures your frontend only uses fields and endpoints defined in the contract.
Core Principles
- Generate, Don't Write: Generate TypeScript clients from OpenAPI specs
- Type Safety: Use generated types throughout the application
- React Query Integration: Combine generated clients with React Query for data fetching
- Error Handling: Handle API errors consistently using generated error types
- Regenerate on Change: Update generated code when OpenAPI spec changes
OpenAPI Code Generation
Installation
npm install --save-dev @openapitools/openapi-generator-cli
Configuration
// openapitools.json
{
"$schema": "node_modules/@openapitools/openapi-generator-cli/config.schema.json",
"spaces": 2,
"generator-cli": {
"version": "7.2.0",
"generators": {
"payment-api": {
"generatorName": "typescript-fetch",
"output": "src/generated/api",
"inputSpec": "../../backend/src/main/resources/api/payment-api.yaml",
"additionalProperties": {
"typescriptThreePlus": true,
"supportsES6": true,
"withInterfaces": true,
"modelPropertyNaming": "camelCase",
"enumPropertyNaming": "UPPERCASE"
}
}
}
}
}
Package.json Scripts
{
"scripts": {
"generate:api": "openapi-generator-cli generate",
"generate:api:watch": "nodemon --watch ../../backend/src/main/resources/api --exec npm run generate:api",
"prebuild": "npm run generate:api",
"pretest": "npm run generate:api"
}
}
Generated Code Structure
src/generated/api/
├── apis/
│ └── PaymentsApi.ts # API client
├── models/
│ ├── PaymentRequest.ts # Request DTO
│ ├── PaymentResponse.ts # Response DTO
│ ├── PaymentStatus.ts # Enum
│ ├── ErrorResponse.ts # Error response
│ └── index.ts # Barrel export
├── runtime.ts # Base API runtime
└── index.ts # Main export
Using Generated Types
Payment Types
// Generated from OpenAPI spec
import {
PaymentRequest,
PaymentResponse,
PaymentStatus,
PaymentListResponse,
ErrorResponse,
ValidationErrorResponse
} from '../generated/api';
// Type-safe request creation
const request: PaymentRequest = {
amount: 100.00,
currency: 'USD', // TypeScript enforces enum values
recipient: 'John Doe',
reference: 'Invoice #12345'
};
// TypeScript knows all response fields
function displayPayment(payment: PaymentResponse) {
console.log(`Payment ${payment.transactionId}`);
console.log(`Amount: ${payment.amount} ${payment.currency}`);
console.log(`Status: ${payment.status}`); // Enum type
console.log(`Created: ${payment.createdAt}`); // Date string
}
// Enum usage
const isPending = payment.status === PaymentStatus.PENDING;
const isCompleted = payment.status === PaymentStatus.COMPLETED;
API Client Configuration
Base Configuration
// src/config/apiConfig.ts
import { Configuration } from '../generated/api';
export const apiConfig = new Configuration({
basePath: process.env.REACT_APP_API_URL || 'http://localhost:8080',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include' // Include cookies for authentication
});
API Client with Authentication
// src/services/apiClient.ts
import { Configuration, PaymentsApi } from '../generated/api';
import { getAuthToken } from './authService';
class ApiClient {
private config: Configuration;
constructor() {
this.config = new Configuration({
basePath: process.env.REACT_APP_API_URL,
accessToken: () => getAuthToken(), // Dynamic token retrieval
headers: {
'Content-Type': 'application/json'
}
});
}
get payments(): PaymentsApi {
return new PaymentsApi(this.config);
}
// Add other API clients as needed
// get accounts(): AccountsApi { ... }
}
export const apiClient = new ApiClient();
Usage in Components
// src/services/paymentService.ts
import { apiClient } from './apiClient';
import { PaymentRequest, PaymentResponse } from '../generated/api';
export class PaymentService {
async createPayment(request: PaymentRequest): Promise<PaymentResponse> {
const response = await apiClient.payments.createPayment({
paymentRequest: request
});
return response;
}
async getPayment(paymentId: string): Promise<PaymentResponse> {
return await apiClient.payments.getPayment({ paymentId });
}
async listPayments(page: number = 0, size: number = 20) {
return await apiClient.payments.listPayments({
page,
size,
sort: ['createdAt,desc']
});
}
}
export const paymentService = new PaymentService();
React Query Integration
Setup
npm install @tanstack/react-query @tanstack/react-query-devtools
// src/App.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 minute
retry: 3,
refetchOnWindowFocus: false
},
mutations: {
retry: 1
}
}
});
function App() {
return (
<QueryClientProvider client={queryClient}>
<Router>
<Routes>{/* Your routes */}</Routes>
</Router>
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
Custom Hooks with React Query
// src/hooks/usePayments.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { paymentService } from '../services/paymentService';
import { PaymentRequest, PaymentResponse } from '../generated/api';
// Query: Fetch single payment
export function usePayment(paymentId: string) {
return useQuery({
queryKey: ['payment', paymentId],
queryFn: () => paymentService.getPayment(paymentId),
enabled: !!paymentId // Only run if paymentId exists
});
}
// Query: Fetch payment list
export function usePayments(page: number = 0, size: number = 20) {
return useQuery({
queryKey: ['payments', page, size],
queryFn: () => paymentService.listPayments(page, size),
keepPreviousData: true // Keep previous page while loading new page
});
}
// Mutation: Create payment
export function useCreatePayment() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (request: PaymentRequest) => paymentService.createPayment(request),
onSuccess: (data: PaymentResponse) => {
// Invalidate and refetch payment list
queryClient.invalidateQueries({ queryKey: ['payments'] });
// Optionally add new payment to cache
queryClient.setQueryData(['payment', data.transactionId], data);
}
});
}
// Mutation: Cancel payment
export function useCancelPayment() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (paymentId: string) => paymentService.cancelPayment(paymentId),
onSuccess: (_, paymentId) => {
// Invalidate specific payment and list
queryClient.invalidateQueries({ queryKey: ['payment', paymentId] });
queryClient.invalidateQueries({ queryKey: ['payments'] });
}
});
}
Component Usage
// src/components/PaymentForm.tsx
import React from 'react';
import { useForm } from 'react-hook-form';
import { useCreatePayment } from '../hooks/usePayments';
import { PaymentRequest } from '../generated/api';
export function PaymentForm() {
const { register, handleSubmit, formState: { errors } } = useForm<PaymentRequest>();
const createPayment = useCreatePayment();
const onSubmit = async (data: PaymentRequest) => {
try {
const result = await createPayment.mutateAsync(data);
console.log('Payment created:', result.transactionId);
// Navigate to success page or show notification
} catch (error) {
console.error('Payment failed:', error);
}
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<label htmlFor="amount">Amount</label>
<input
id="amount"
type="number"
step="0.01"
{...register('amount', {
required: 'Amount is required',
min: { value: 0.01, message: 'Amount must be positive' }
})}
/>
{errors.amount && <span>{errors.amount.message}</span>}
</div>
<div>
<label htmlFor="currency">Currency</label>
<select id="currency" {...register('currency', { required: true })}>
<option value="USD">USD</option>
<option value="EUR">EUR</option>
<option value="GBP">GBP</option>
</select>
</div>
<div>
<label htmlFor="recipient">Recipient</label>
<input
id="recipient"
{...register('recipient', { required: 'Recipient is required' })}
/>
{errors.recipient && <span>{errors.recipient.message}</span>}
</div>
<button type="submit" disabled={createPayment.isPending}>
{createPayment.isPending ? 'Processing...' : 'Submit Payment'}
</button>
{createPayment.isError && (
<div className="error">
Error: {createPayment.error.message}
</div>
)}
</form>
);
}
// src/components/PaymentList.tsx
import React from 'react';
import { usePayments } from '../hooks/usePayments';
import { PaymentResponse } from '../generated/api';
export function PaymentList() {
const [page, setPage] = React.useState(0);
const { data, isLoading, isError, error } = usePayments(page, 20);
if (isLoading) {
return <div>Loading payments...</div>;
}
if (isError) {
return <div>Error loading payments: {error.message}</div>;
}
return (
<div>
<h2>Payments</h2>
<table>
<thead>
<tr>
<th>Transaction ID</th>
<th>Amount</th>
<th>Recipient</th>
<th>Status</th>
<th>Created</th>
</tr>
</thead>
<tbody>
{data.content.map((payment: PaymentResponse) => (
<tr key={payment.transactionId}>
<td>{payment.transactionId}</td>
<td>{payment.amount} {payment.currency}</td>
<td>{payment.recipient}</td>
<td>{payment.status}</td>
<td>{new Date(payment.createdAt).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
<div>
<button onClick={() => setPage(p => Math.max(0, p - 1))} disabled={page === 0}>
Previous
</button>
<span>Page {page + 1} of {data.totalPages}</span>
<button onClick={() => setPage(p => p + 1)} disabled={page >= data.totalPages - 1}>
Next
</button>
</div>
</div>
);
}
Error Handling
Typed Error Responses
// src/utils/apiErrorHandler.ts
import { ErrorResponse, ValidationErrorResponse } from '../generated/api';
export class ApiError extends Error {
constructor(
public status: number,
public errorResponse: ErrorResponse,
public validationErrors?: ValidationErrorResponse
) {
super(errorResponse.detail);
this.name = 'ApiError';
}
isValidationError(): boolean {
return this.status === 422 && !!this.validationErrors;
}
getFieldErrors(): Record<string, string> {
if (!this.validationErrors?.errors) {
return {};
}
return this.validationErrors.errors.reduce((acc, error) => {
acc[error.field] = error.message;
return acc;
}, {} as Record<string, string>);
}
}
export async function handleApiError(response: Response): Promise<ApiError> {
const contentType = response.headers.get('content-type');
if (contentType?.includes('application/json')) {
const errorData = await response.json();
if (response.status === 422 && errorData.errors) {
return new ApiError(response.status, errorData, errorData);
}
return new ApiError(response.status, errorData);
}
// Fallback for non-JSON errors
return new ApiError(response.status, {
type: 'https://api.bank.com/errors/unknown',
title: 'Unknown Error',
status: response.status,
detail: await response.text(),
instance: response.url
});
}
Error Handling in Components
// src/components/PaymentFormWithErrors.tsx
import React from 'react';
import { useCreatePayment } from '../hooks/usePayments';
import { ApiError } from '../utils/apiErrorHandler';
export function PaymentFormWithErrors() {
const createPayment = useCreatePayment();
const [fieldErrors, setFieldErrors] = React.useState<Record<string, string>>({});
const onSubmit = async (data: PaymentRequest) => {
try {
setFieldErrors({});
await createPayment.mutateAsync(data);
} catch (error) {
if (error instanceof ApiError && error.isValidationError()) {
// Handle validation errors
setFieldErrors(error.getFieldErrors());
} else {
// Handle other errors
console.error('Payment failed:', error);
}
}
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<input {...register('amount')} />
{fieldErrors.amount && <span className="error">{fieldErrors.amount}</span>}
</div>
{createPayment.isError && !fieldErrors && (
<div className="error">
{createPayment.error.message}
</div>
)}
<button type="submit">Submit Payment</button>
</form>
);
}
Best Practices
Regenerate on Spec Changes
# Watch for OpenAPI spec changes and regenerate
npm run generate:api:watch
# Or in CI pipeline
npm run generate:api
git diff --exit-code src/generated/api || (echo "Generated code changed!" && exit 1)
Version Control Generated Code
# DO commit generated API code
# src/generated/api/
# Rationale: Ensures everyone has same types
# Alternative: Generate in CI and publish as npm package
Type Guards for Enums
import { PaymentStatus } from '../generated/api';
export function isPaymentStatus(value: string): value is PaymentStatus {
return Object.values(PaymentStatus).includes(value as PaymentStatus);
}
// Usage
const status = urlParams.get('status');
if (status && isPaymentStatus(status)) {
// TypeScript knows status is PaymentStatus
filterByStatus(status);
}
Centralize API Configuration
// src/config/api.ts
export const API_CONFIG = {
baseUrl: process.env.REACT_APP_API_URL || 'http://localhost:8080',
timeout: 30000,
retries: 3,
headers: {
'Content-Type': 'application/json'
}
} as const;
Further Reading
- OpenAPI Specifications - Creating OpenAPI specs
- API Contract Testing - Validating contracts
- React State Management - Zustand and React Query patterns
- React Testing - Testing components with API calls
External Resources:
Summary
Key Takeaways:
- Generate Types: Use OpenAPI Generator to create TypeScript types and clients
- Type Safety: Generated types ensure compile-time safety throughout application
- React Query: Combine generated clients with React Query for data fetching
- Error Handling: Use generated error types for consistent error handling
- Regenerate on Change: Update generated code when OpenAPI spec changes
- Version Control: Commit generated code to ensure team consistency
- Custom Hooks: Wrap generated clients in custom React Query hooks
- Validation Errors: Handle 422 validation errors with field-level error display