React hat keine Datenbank. Kein Backend. Keine Business-Logic. Eine React-App ist Client-Code im Browser – isoliert, stateless nach jedem Reload, komplett abhängig von externen Datenquellen. Diese Datenquellen sind APIs: REST-Endpoints, GraphQL-Server, gRPC-Services. Der Standard-Fall: HTTP-basierte REST-APIs mit JSON.
Das Problem: Manuell API-Calls zu schreiben ist fehleranfällig. Endpoints ändern sich, Response-Strukturen mutieren, neue Felder kommen, alte verschwinden. TypeScript-Types müssen händisch synchronisiert werden. Ein Tippfehler im Endpoint-String? Runtime-Error. Fehlende Properties im Response? Runtime-Error. Das skaliert nicht.
Die Lösung: Contract-Driven Development mit OpenAPI. Der API-Contract ist die Single Source of Truth. Aus diesem Contract generiert man Client-Code – typsicher, automatisch, konsistent.
OpenAPI (früher Swagger) ist eine Spezifikation zur Beschreibung von
REST-APIs. Eine YAML- oder JSON-Datei definiert: - Endpoints
(/users, /products/{id}) - HTTP-Methoden (GET,
POST, PUT, DELETE) - Request-Parameter (Query, Path, Body) -
Response-Schemas (Success, Error) - Datentypen und Validierung
Beispiel: Einfache User-API
# openapi.yaml
openapi: 3.0.0
info:
title: User Management API
version: 1.0.0
paths:
/users:
get:
summary: List all users
operationId: listUsers
parameters:
- name: page
in: query
schema:
type: integer
default: 1
- name: limit
in: query
schema:
type: integer
default: 10
responses:
'200':
description: Successful response
content:
application/json:
schema:
type: object
properties:
users:
type: array
items:
$ref: '#/components/schemas/User'
total:
type: integer
page:
type: integer
post:
summary: Create a new user
operationId: createUser
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateUserRequest'
responses:
'201':
description: User created
content:
application/json:
schema:
$ref: '#/components/schemas/User'
'400':
description: Validation error
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/users/{userId}:
get:
summary: Get user by ID
operationId: getUser
parameters:
- name: userId
in: path
required: true
schema:
type: string
format: uuid
responses:
'200':
description: User found
content:
application/json:
schema:
$ref: '#/components/schemas/User'
'404':
description: User not found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
components:
schemas:
User:
type: object
required:
- id
- email
- name
properties:
id:
type: string
format: uuid
email:
type: string
format: email
name:
type: string
role:
type: string
enum: [admin, user, guest]
createdAt:
type: string
format: date-time
CreateUserRequest:
type: object
required:
- email
- name
properties:
email:
type: string
format: email
name:
type: string
role:
type: string
enum: [admin, user, guest]
default: user
Error:
type: object
properties:
message:
type: string
code:
type: stringDieser Contract ist: - Maschinenlesbar: Tools können daraus Code generieren - Versioniert: In Git neben Code - Single Source of Truth: Definiert exakt, was API kann - Technologieneutral: React, Angular, Vue können alle daraus generieren
Statt händisch Fetch-Calls zu schreiben, generiert man TypeScript-Code aus der OpenAPI-Spec.
npm install --save-dev openapi-typescript-codegen// package.json
{
"scripts": {
"generate:api": "openapi --input ./openapi.yaml --output ./src/api --client fetch"
}
}npm run generate:apiGenerierter Output:
src/api/
├── index.ts
├── models/
│ ├── User.ts
│ ├── CreateUserRequest.ts
│ └── Error.ts
├── services/
│ └── DefaultService.ts
└── core/
├── ApiError.ts
├── ApiRequestOptions.ts
├── ApiResult.ts
└── request.ts
Generierte TypeScript-Typen:
// src/api/models/User.ts
export type User = {
id: string;
email: string;
name: string;
role?: 'admin' | 'user' | 'guest';
createdAt?: string;
};
// src/api/models/CreateUserRequest.ts
export type CreateUserRequest = {
email: string;
name: string;
role?: 'admin' | 'user' | 'guest';
};
// src/api/models/Error.ts
export type Error = {
message?: string;
code?: string;
};Generierter Service:
// src/api/services/DefaultService.ts
import type { User } from '../models/User';
import type { CreateUserRequest } from '../models/CreateUserRequest';
export class DefaultService {
/**
* List all users
* @param page
* @param limit
* @returns any Successful response
* @throws ApiError
*/
public static listUsers(
page: number = 1,
limit: number = 10,
): CancelablePromise<{
users?: Array<User>;
total?: number;
page?: number;
}> {
return __request(OpenAPI, {
method: 'GET',
url: '/users',
query: {
'page': page,
'limit': limit,
},
});
}
/**
* Create a new user
* @param requestBody
* @returns User User created
* @throws ApiError
*/
public static createUser(
requestBody: CreateUserRequest,
): CancelablePromise<User> {
return __request(OpenAPI, {
method: 'POST',
url: '/users',
body: requestBody,
mediaType: 'application/json',
errors: {
400: `Validation error`,
},
});
}
/**
* Get user by ID
* @param userId
* @returns User User found
* @throws ApiError
*/
public static getUser(
userId: string,
): CancelablePromise<User> {
return __request(OpenAPI, {
method: 'GET',
url: '/users/{userId}',
path: {
'userId': userId,
},
errors: {
404: `User not found`,
},
});
}
}Orval generiert nicht nur Types, sondern auch React Query Hooks direkt.
npm install --save-dev orval// orval.config.ts
import { defineConfig } from 'orval';
export default defineConfig({
api: {
input: './openapi.yaml',
output: {
mode: 'tags-split',
target: './src/api/generated',
client: 'react-query',
mock: true,
override: {
mutator: {
path: './src/api/client.ts',
name: 'customClient'
}
}
}
}
});// src/api/client.ts - Custom Fetch-Wrapper
export const customClient = async <T>(
config: RequestConfig
): Promise<T> => {
const response = await fetch(config.url, {
method: config.method,
headers: {
'Content-Type': 'application/json',
...config.headers
},
body: config.data ? JSON.stringify(config.data) : undefined
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'API Error');
}
return response.json();
};npx orvalGenerierte React Query Hooks:
// src/api/generated/users.ts
import { useQuery, useMutation, UseQueryOptions, UseMutationOptions } from '@tanstack/react-query';
import type { User, CreateUserRequest } from './models';
export const useListUsers = <TData = { users?: User[]; total?: number; page?: number }>(
params?: { page?: number; limit?: number },
options?: UseQueryOptions<TData>
) => {
return useQuery<TData>({
queryKey: ['users', params],
queryFn: () => customClient<TData>({
url: '/users',
method: 'GET',
params
}),
...options
});
};
export const useGetUser = <TData = User>(
userId: string,
options?: UseQueryOptions<TData>
) => {
return useQuery<TData>({
queryKey: ['users', userId],
queryFn: () => customClient<TData>({
url: `/users/${userId}`,
method: 'GET'
}),
...options
});
};
export const useCreateUser = <TData = User>(
options?: UseMutationOptions<TData, Error, CreateUserRequest>
) => {
return useMutation<TData, Error, CreateUserRequest>({
mutationFn: (data) => customClient<TData>({
url: '/users',
method: 'POST',
data
}),
...options
});
};Mit openapi-typescript-codegen (Plain Fetch):
// hooks/useUsers.ts
import { useState, useEffect } from 'react';
import { DefaultService } from '../api/services/DefaultService';
import type { User } from '../api/models/User';
export function useUsers(page: number = 1, limit: number = 10) {
const [users, setUsers] = useState<User[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
const fetchUsers = async () => {
try {
setLoading(true);
const response = await DefaultService.listUsers(page, limit);
setUsers(response.users || []);
setTotal(response.total || 0);
} catch (err) {
setError(err instanceof Error ? err : new Error('Failed to fetch users'));
} finally {
setLoading(false);
}
};
fetchUsers();
}, [page, limit]);
return { users, total, loading, error };
}
// components/UserList.tsx
import { useUsers } from '../hooks/useUsers';
export function UserList() {
const { users, total, loading, error } = useUsers(1, 20);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<div>
<h2>Users ({total})</h2>
<ul>
{users.map(user => (
<li key={user.id}>
{user.name} ({user.email}) - {user.role}
</li>
))}
</ul>
</div>
);
}
Mit orval (React Query Hooks):
// components/UserList.tsx
import { useListUsers, useCreateUser } from '../api/generated/users';
import { useState } from 'react';
export function UserList() {
const [page, setPage] = useState(1);
// Auto-generated Hook mit Caching, Refetching, etc.
const { data, isLoading, error } = useListUsers({ page, limit: 20 });
const createMutation = useCreateUser({
onSuccess: () => {
// Invalidate & refetch
queryClient.invalidateQueries(['users']);
}
});
const handleCreateUser = async () => {
await createMutation.mutateAsync({
name: 'New User',
email: 'user@example.com',
role: 'user'
});
};
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<div>
<h2>Users ({data?.total})</h2>
<button onClick={handleCreateUser}>Create User</button>
<ul>
{data?.users?.map(user => (
<li key={user.id}>
{user.name} ({user.email})
</li>
))}
</ul>
<button onClick={() => setPage(p => p - 1)} disabled={page === 1}>
Previous
</button>
<button onClick={() => setPage(p => p + 1)}>
Next
</button>
</div>
);
}
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { exec } from 'child_process';
import { promisify } from 'util';
const execAsync = promisify(exec);
const generateApiPlugin = () => ({
name: 'generate-api',
async buildStart() {
console.log('Generating API client from OpenAPI spec...');
try {
await execAsync('npm run generate:api');
console.log('API client generated successfully');
} catch (error) {
console.error('Failed to generate API client:', error);
throw error;
}
}
});
export default defineConfig({
plugins: [
generateApiPlugin(), // Generiert vor jedem Build
react()
]
});Oder als Pre-Build Script:
// package.json
{
"scripts": {
"generate:api": "orval",
"prebuild": "npm run generate:api",
"build": "vite build",
"dev": "vite"
}
}DTO (Data Transfer Object): - Repräsentiert Daten wie sie über Netzwerk übertragen werden - JSON-Schema aus API - Im Frontend: Generated Types aus OpenAPI - Kein Verhalten, nur Daten
DAO (Data Access Object): - Backend-Pattern für Datenbankzugriff - Kapselt SQL/ORM-Queries - Nicht relevant für Frontend - React kennt nur DTOs
// DTO - Generated aus OpenAPI
export type User = {
id: string;
email: string;
name: string;
role?: 'admin' | 'user' | 'guest';
};
// Nicht DTO: UI-spezifisches Model
interface UserFormData {
email: string;
name: string;
role: 'admin' | 'user' | 'guest';
agreeToTerms: boolean; // Nur UI-Feld
}
// Mapper: DTO ↔ UI Model
function mapUserToFormData(user: User): UserFormData {
return {
email: user.email,
name: user.name,
role: user.role || 'user',
agreeToTerms: false
};
}
function mapFormDataToDTO(formData: UserFormData): CreateUserRequest {
return {
email: formData.email,
name: formData.name,
role: formData.role
};
}// src/api/client.ts
import { OpenAPI } from './generated/core/OpenAPI';
// Base URL konfigurieren
OpenAPI.BASE = import.meta.env.VITE_API_URL || 'https://api.example.com';
// Auth Token setzen
export function setAuthToken(token: string | null) {
if (token) {
OpenAPI.TOKEN = token;
} else {
OpenAPI.TOKEN = undefined;
}
}
// Oder mit Custom Fetch
export const authenticatedClient = async <T>(
config: RequestConfig
): Promise<T> => {
const token = localStorage.getItem('authToken');
const response = await fetch(config.url, {
method: config.method,
headers: {
'Content-Type': 'application/json',
...(token && { Authorization: `Bearer ${token}` }),
...config.headers
},
body: config.data ? JSON.stringify(config.data) : undefined
});
if (response.status === 401) {
// Token expired, redirect to login
window.location.href = '/login';
throw new Error('Unauthorized');
}
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'API Error');
}
return response.json();
};// App.tsx
import { useEffect } from 'react';
import { setAuthToken } from './api/client';
import { useAuth } from './contexts/AuthContext';
function App() {
const { token } = useAuth();
useEffect(() => {
setAuthToken(token);
}, [token]);
return <div>{/* App */}</div>;
}
OpenAPI definiert Error-Responses. Generated Code wirft typisierte Errors.
// OpenAPI Error-Definition
components:
schemas:
ValidationError:
type: object
properties:
message:
type: string
errors:
type: array
items:
type: object
properties:
field:
type: string
message:
type: string// Generated Type
export type ValidationError = {
message?: string;
errors?: Array<{
field?: string;
message?: string;
}>;
};// React Component mit Error Handling
import { useCreateUser } from '../api/generated/users';
import type { ValidationError } from '../api/models/ValidationError';
function UserForm() {
const [errors, setErrors] = useState<ValidationError | null>(null);
const createMutation = useCreateUser({
onError: (error) => {
// Generated Error Type
if (error.status === 400) {
setErrors(error.body as ValidationError);
}
}
});
const handleSubmit = async (data: FormData) => {
setErrors(null);
try {
await createMutation.mutateAsync({
email: data.email,
name: data.name
});
} catch (error) {
// Handled by onError
}
};
return (
<form onSubmit={handleSubmit}>
{errors?.errors?.map(err => (
<div key={err.field} className="error">
{err.field}: {err.message}
</div>
))}
{/* Form fields */}
</form>
);
}
Generated Code kann Mock-Server generieren.
npx orval --mode mocks// src/api/mocks/users.mock.ts - Auto-generated
import { faker } from '@faker-js/faker';
import type { User } from '../models/User';
export const getUsersMock = (): { users: User[]; total: number } => ({
users: Array.from({ length: 10 }, () => ({
id: faker.string.uuid(),
email: faker.internet.email(),
name: faker.person.fullName(),
role: faker.helpers.arrayElement(['admin', 'user', 'guest']),
createdAt: faker.date.past().toISOString()
})),
total: 100
});// src/api/client.ts - MSW Integration
import { setupWorker } from 'msw';
import { handlers } from './mocks/handlers';
export const worker = setupWorker(...handlers);
// In Development: Mock Server starten
if (import.meta.env.DEV) {
worker.start();
}| Tool | Client | React Query | Mocks | Bundle Size | Customization |
|---|---|---|---|---|---|
| openapi-typescript-codegen | Fetch, Axios | ❌ Nein | ✅ Ja | Klein | Mittel |
| orval | Fetch, Axios | ✅ Ja | ✅ Ja | Mittel | Hoch |
| swagger-typescript-api | Fetch, Axios | ❌ Nein | ❌ Nein | Klein | Niedrig |
| openapi-generator-cli | Fetch, Axios | ❌ Nein | ❌ Nein | Groß | Sehr hoch |
Empfehlung: - Einfach, ohne React Query: openapi-typescript-codegen - Mit React Query: orval - Maximum Control: openapi-generator-cli - Schnellstart: swagger-typescript-api
// package.json
{
"scripts": {
"generate:api": "orval",
"generate:api:watch": "orval --watch"
}
}Parallel zu npm run dev:
# Terminal 1
npm run dev
# Terminal 2
npm run generate:api:watchBei Änderung in openapi.yaml → Auto-Regeneration →
Hot-Reload in Browser.
Fehler 1: Generated Code im Git
# ❌ Falsch: Generated Code committen
git add src/api/generated
# ✓ Richtig: .gitignore
# .gitignore
src/api/generated/Regeneriere bei jedem Build. Oder: Committe Generated Code wenn Team OpenAPI-Spec nicht hat.
Fehler 2: Manuelle Änderungen in Generated Files
// ❌ Falsch: Generated File editieren
// src/api/generated/users.ts
export const useListUsers = () => {
// Custom logic hier ← Wird beim nächsten Generate überschrieben!
};
// ✓ Richtig: Wrapper schreiben
// src/hooks/useUsers.ts
import { useListUsers as useListUsersGenerated } from '../api/generated/users';
export function useListUsers() {
const query = useListUsersGenerated();
// Custom logic hier
return query;
}Fehler 3: Falsche Base URL
// ❌ Falsch: Hardcoded Production URL
OpenAPI.BASE = 'https://api.production.com';
// ✓ Richtig: Environment-Variable
OpenAPI.BASE = import.meta.env.VITE_API_URL;# .env.development
VITE_API_URL=http://localhost:3000
# .env.production
VITE_API_URL=https://api.production.comFehler 4: Types und Runtime nicht synchron
// OpenAPI sagt: email ist required
// Backend ändert: email ist jetzt optional
// Generated Code ist veraltet!
// ✓ Lösung: Regeneriere regelmäßig
npm run generate:apiFehler 5: Keine Validation der OpenAPI-Spec
# ✓ Validate vor Generate
npx swagger-cli validate openapi.yaml
npm run generate:api# .github/workflows/build.yml
name: Build
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 18
# OpenAPI Spec validieren
- name: Validate OpenAPI Spec
run: npx swagger-cli validate openapi.yaml
# API Client generieren
- name: Generate API Client
run: npm run generate:api
# TypeScript Check
- name: TypeScript Check
run: npx tsc --noEmit
# Build
- name: Build
run: npm run buildContract-Driven Development mit OpenAPI ist kein Nice-to-Have für professionelle React-Apps – es ist Standard. Die API ist der Contract. Der Contract ist Typisierung. Typisierung ist Sicherheit. Code-Generierung eliminiert Boilerplate und hält Frontend/Backend synchron. React profitiert massiv: Autocomplete, Compile-Zeit-Validierung, konsistente Error-Handling – alles automatisch aus der API-Spec generiert.