π Working with APIs
You've learned data fetching fundamentals, custom hooks, and advanced patterns. Now it's time to master real-world API integration! Most React applications communicate with backend servers through APIs (Application Programming Interfaces). Whether you're building a social media app, an e-commerce site, or a productivity tool, you'll need to understand RESTful APIs, handle authentication, manage errors gracefully, and structure your code for maintainability. In this lesson, we'll cover everything you need to build production-ready API integrations. Let's dive into the world of APIs! π
π― Learning Objectives
By the end of this lesson, you will be able to:
- Understand RESTful API architecture and conventions
- Implement all CRUD operations (Create, Read, Update, Delete)
- Structure API calls in a maintainable service layer
- Handle authentication with tokens (JWT)
- Implement proper error handling strategies
- Type API requests and responses with TypeScript
- Use environment variables for configuration
- Handle different HTTP status codes appropriately
- Build a reusable API client with interceptors
- Implement request/response transformations
Estimated Time: 75-90 minutes
Project: Build a complete API integration layer for a task management app
π In This Lesson
ποΈ RESTful API Fundamentals
REST (Representational State Transfer) is an architectural style for designing networked applications. Most modern web APIs follow REST principles. Let's understand the fundamentals!
π Definition
REST API: An API that uses HTTP requests to GET, POST, PUT, PATCH, and DELETE data. It treats data as resources (like users, posts, products) that can be accessed via URLs (endpoints) and manipulated using standard HTTP methods.
HTTP Methods (Verbs)
REST APIs use HTTP methods to indicate the desired action:
| Method | Purpose | Example | Idempotent? |
|---|---|---|---|
| GET | Retrieve data | GET /api/users/123 |
β Yes |
| POST | Create new resource | POST /api/users |
β No |
| PUT | Replace entire resource | PUT /api/users/123 |
β Yes |
| PATCH | Partial update | PATCH /api/users/123 |
β οΈ Maybe |
| DELETE | Remove resource | DELETE /api/users/123 |
β Yes |
π‘ What is Idempotent?
Idempotent means making the same request multiple times has the same effect as making it once. GET, PUT, and DELETE are idempotent:
- GET /users/123 β Always returns the same user (unless data changed)
- DELETE /users/123 β First call deletes, subsequent calls do nothing (already deleted)
- PUT /users/123 β Always sets user to the same state
POST is NOT idempotent:
- POST /users β Creates a NEW user each time (duplicate entries!)
HTTP Status Codes
APIs communicate success or failure using status codes:
Success Codes (2xx)
- 200 OK - Request succeeded (GET, PUT, PATCH)
- 201 Created - Resource created successfully (POST)
- 204 No Content - Success but no response body (DELETE)
Client Error Codes (4xx)
- 400 Bad Request - Invalid request data
- 401 Unauthorized - Authentication required or failed
- 403 Forbidden - Authenticated but not authorized
- 404 Not Found - Resource doesn't exist
- 422 Unprocessable Entity - Validation errors
Server Error Codes (5xx)
- 500 Internal Server Error - Server-side error
- 502 Bad Gateway - Invalid response from upstream server
- 503 Service Unavailable - Server temporarily down
RESTful URL Structure
REST APIs use predictable URL patterns:
β Good RESTful URLs
// Collections (plural nouns)
GET /api/users // Get all users
POST /api/users // Create a new user
// Specific resources (by ID)
GET /api/users/123 // Get user with ID 123
PUT /api/users/123 // Replace user 123
PATCH /api/users/123 // Update user 123
DELETE /api/users/123 // Delete user 123
// Nested resources
GET /api/users/123/posts // Get posts by user 123
POST /api/users/123/posts // Create post for user 123
GET /api/posts/456/comments // Get comments for post 456
// Filtering and pagination (query params)
GET /api/users?role=admin&page=2&limit=20
GET /api/posts?author=123&status=published&sort=createdAt:desc
β Bad RESTful URLs (Avoid These!)
// Using verbs in URLs (the HTTP method is the verb!)
GET /api/getUsers β Should be: GET /api/users
POST /api/createUser β Should be: POST /api/users
POST /api/deleteUser/123 β Should be: DELETE /api/users/123
// Inconsistent naming
GET /api/user β Should be: /api/users (plural)
GET /api/Users β Should be: /api/users (lowercase)
GET /api/user-list β Should be: /api/users
// Not resource-based
POST /api/login β οΈ Sometimes acceptable for actions
GET /api/search?q=test β οΈ Sometimes acceptable for complex queries
Anatomy of an API Request
π Interactive HTTP Request/Response Explorer
Click on different parts of the request or response to learn more
π Click on any part of the diagram to learn more about it.
A Complete HTTP Request
// Request Line
POST /api/users HTTP/1.1
// Headers
Host: api.example.com
Content-Type: application/json
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Accept: application/json
User-Agent: MyApp/1.0
// Body (for POST, PUT, PATCH)
{
"name": "Jane Doe",
"email": "jane@example.com",
"role": "admin"
}
A Complete HTTP Response
// Status Line
HTTP/1.1 201 Created
// Headers
Content-Type: application/json
Location: /api/users/456
Cache-Control: no-cache
X-RateLimit-Remaining: 99
// Body
{
"id": 456,
"name": "Jane Doe",
"email": "jane@example.com",
"role": "admin",
"createdAt": "2024-01-15T10:30:00Z"
}
Common API Patterns
Pagination
// Offset-based
GET /api/posts?page=2&limit=20
// Cursor-based
GET /api/posts?cursor=abc123&limit=20
// Response includes metadata
{
"data": [...],
"pagination": {
"total": 500,
"page": 2,
"limit": 20,
"totalPages": 25,
"hasNext": true,
"hasPrevious": true
}
}
Filtering and Sorting
// Filtering
GET /api/users?role=admin&status=active&createdAfter=2024-01-01
// Sorting
GET /api/posts?sort=createdAt:desc,title:asc
// Searching
GET /api/products?q=laptop&category=electronics
Field Selection (Sparse Fieldsets)
// Only return specific fields
GET /api/users?fields=id,name,email
// Response
{
"id": 123,
"name": "John Doe",
"email": "john@example.com"
// Other fields omitted
}
Relationship Inclusion
// Include related resources
GET /api/posts/123?include=author,comments
// Response
{
"id": 123,
"title": "My Post",
"author": {
"id": 456,
"name": "Jane Doe"
},
"comments": [
{ "id": 789, "text": "Great post!" }
]
}
β RESTful API Best Practices
- Use nouns, not verbs - URLs represent resources, methods represent actions
- Use plural names - /users not /user (collections are plural)
- Use lowercase - /api/users not /api/Users
- Use hyphens, not underscores - /user-profiles not /user_profiles
- Nest resources logically - /users/123/posts makes sense
- Version your API - /api/v1/users or api.example.com/v1/users
- Return appropriate status codes - 201 for creation, 404 for not found, etc.
- Use query params for filters - /users?role=admin&active=true
π CRUD Operations
CRUD stands for Create, Read, Update, Deleteβthe four basic operations for persistent storage. Let's implement all of them with proper TypeScript typing!
π CRUD Overview
CREATE: Add new data (POST)
READ: Retrieve existing data (GET)
UPDATE: Modify existing data (PUT/PATCH)
DELETE: Remove data (DELETE)
π Interactive CRUD Operations Visualizer
Click each operation to see how it works and watch the database update
π Click on a CRUD operation to see it in action!
Defining Types for Our API
First, let's define TypeScript interfaces for our data:
// types/api.ts
// Base user type from API
interface User {
id: number;
name: string;
email: string;
role: 'user' | 'admin';
avatar?: string;
createdAt: string;
updatedAt: string;
}
// Data for creating a new user (no id, timestamps)
interface CreateUserData {
name: string;
email: string;
role?: 'user' | 'admin';
avatar?: string;
}
// Data for updating a user (all fields optional)
interface UpdateUserData {
name?: string;
email?: string;
role?: 'user' | 'admin';
avatar?: string;
}
// API response wrapper
interface ApiResponse<T> {
data: T;
message?: string;
}
// Paginated response
interface PaginatedResponse<T> {
data: T[];
pagination: {
total: number;
page: number;
limit: number;
totalPages: number;
};
}
// Error response
interface ApiError {
message: string;
errors?: Record<string, string[]>; // Validation errors
statusCode: number;
}
CREATE: Adding New Resources
β Create User (POST)
async function createUser(userData: CreateUserData): Promise<User> {
try {
const response = await fetch('https://api.example.com/api/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify(userData)
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result: ApiResponse<User> = await response.json();
return result.data;
} catch (error) {
console.error('Failed to create user:', error);
throw error;
}
}
// Usage in a component
const CreateUserForm: React.FC = () => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const userData: CreateUserData = {
name: formData.get('name') as string,
email: formData.get('email') as string,
role: formData.get('role') as 'user' | 'admin',
};
setLoading(true);
setError(null);
try {
const newUser = await createUser(userData);
console.log('User created:', newUser);
// Handle success (redirect, show message, etc.)
} catch (err) {
setError('Failed to create user');
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handleSubmit}>
<input name="name" required placeholder="Name" />
<input name="email" type="email" required placeholder="Email" />
<select name="role">
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
<button type="submit" disabled={loading}>
{loading ? 'Creating...' : 'Create User'}
</button>
{error && <p style={{ color: 'red' }}>{error}</p>}
</form>
);
};
READ: Fetching Resources
Read Single User (GET)
async function getUser(userId: number): Promise<User> {
try {
const response = await fetch(`https://api.example.com/api/users/${userId}`, {
method: 'GET',
headers: {
'Accept': 'application/json'
}
});
if (!response.ok) {
if (response.status === 404) {
throw new Error('User not found');
}
throw new Error(`HTTP error! status: ${response.status}`);
}
const result: ApiResponse<User> = await response.json();
return result.data;
} catch (error) {
console.error('Failed to fetch user:', error);
throw error;
}
}
Read Multiple Users (GET with Pagination)
interface GetUsersParams {
page?: number;
limit?: number;
role?: 'user' | 'admin';
search?: string;
sortBy?: string;
}
async function getUsers(params: GetUsersParams = {}): Promise<PaginatedResponse<User>> {
// Build query string from params
const queryParams = new URLSearchParams();
if (params.page) queryParams.append('page', params.page.toString());
if (params.limit) queryParams.append('limit', params.limit.toString());
if (params.role) queryParams.append('role', params.role);
if (params.search) queryParams.append('q', params.search);
if (params.sortBy) queryParams.append('sort', params.sortBy);
const queryString = queryParams.toString();
const url = `https://api.example.com/api/users${queryString ? `?${queryString}` : ''}`;
try {
const response = await fetch(url, {
method: 'GET',
headers: {
'Accept': 'application/json'
}
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Failed to fetch users:', error);
throw error;
}
}
// Usage
const UserList: React.FC = () => {
const [users, setUsers] = useState<User[]>([]);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [loading, setLoading] = useState(false);
useEffect(() => {
const fetchUsers = async () => {
setLoading(true);
try {
const response = await getUsers({ page, limit: 20 });
setUsers(response.data);
setTotalPages(response.pagination.totalPages);
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
};
fetchUsers();
}, [page]);
return (
<div>
{loading && <div>Loading...</div>}
{users.map(user => (
<div key={user.id}>{user.name}</div>
))}
<button onClick={() => setPage(p => p - 1)} disabled={page === 1}>
Previous
</button>
<span>Page {page} of {totalPages}</span>
<button onClick={() => setPage(p => p + 1)} disabled={page === totalPages}>
Next
</button>
</div>
);
};
UPDATE: Modifying Existing Resources
There are two HTTP methods for updating: PUT (full replacement) and PATCH (partial update).
PUT vs PATCH
| PUT | PATCH |
|---|---|
| Replaces entire resource | Updates only specified fields |
| Must send all fields | Send only changed fields |
| Idempotent | Usually idempotent |
| Missing fields β defaults or null | Missing fields β unchanged |
β Update User (PATCH)
async function updateUser(
userId: number,
updates: UpdateUserData
): Promise<User> {
try {
const response = await fetch(`https://api.example.com/api/users/${userId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify(updates)
});
if (!response.ok) {
if (response.status === 404) {
throw new Error('User not found');
}
if (response.status === 422) {
const errorData = await response.json();
throw new Error(errorData.message || 'Validation failed');
}
throw new Error(`HTTP error! status: ${response.status}`);
}
const result: ApiResponse<User> = await response.json();
return result.data;
} catch (error) {
console.error('Failed to update user:', error);
throw error;
}
}
// Usage in a component
const EditUserForm: React.FC<{ user: User }> = ({ user }) => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
// Only include changed fields
const updates: UpdateUserData = {};
const name = formData.get('name') as string;
const email = formData.get('email') as string;
if (name !== user.name) updates.name = name;
if (email !== user.email) updates.email = email;
// If nothing changed, don't make request
if (Object.keys(updates).length === 0) {
setError('No changes detected');
return;
}
setLoading(true);
setError(null);
setSuccess(false);
try {
await updateUser(user.id, updates);
setSuccess(true);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update user');
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handleSubmit}>
<input
name="name"
defaultValue={user.name}
required
placeholder="Name"
/>
<input
name="email"
type="email"
defaultValue={user.email}
required
placeholder="Email"
/>
<button type="submit" disabled={loading}>
{loading ? 'Updating...' : 'Update User'}
</button>
{error && <p style={{ color: 'red' }}>{error}</p>}
{success && <p style={{ color: 'green' }}>User updated successfully!</p>}
</form>
);
};
Update User (PUT - Full Replacement)
async function replaceUser(
userId: number,
userData: CreateUserData // Same as create, but for existing user
): Promise<User> {
try {
const response = await fetch(`https://api.example.com/api/users/${userId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify(userData)
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result: ApiResponse<User> = await response.json();
return result.data;
} catch (error) {
console.error('Failed to replace user:', error);
throw error;
}
}
// PUT example: All fields must be provided!
await replaceUser(123, {
name: 'Jane Doe',
email: 'jane@example.com',
role: 'admin'
// If avatar is not provided, it will be set to null or default
});
DELETE: Removing Resources
β Delete User (DELETE)
async function deleteUser(userId: number): Promise<void> {
try {
const response = await fetch(`https://api.example.com/api/users/${userId}`, {
method: 'DELETE',
headers: {
'Accept': 'application/json'
}
});
if (!response.ok) {
if (response.status === 404) {
throw new Error('User not found');
}
if (response.status === 403) {
throw new Error('You do not have permission to delete this user');
}
throw new Error(`HTTP error! status: ${response.status}`);
}
// DELETE typically returns 204 No Content (no body)
// Or 200 OK with a success message
if (response.status === 204) {
return; // No content
}
// If there's a body, parse it
const result = await response.json();
console.log(result.message); // e.g., "User deleted successfully"
} catch (error) {
console.error('Failed to delete user:', error);
throw error;
}
}
// Usage with confirmation
const DeleteUserButton: React.FC<{ user: User; onDeleted: () => void }> = ({
user,
onDeleted
}) => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleDelete = async () => {
// Always confirm destructive actions!
const confirmed = window.confirm(
`Are you sure you want to delete ${user.name}? This action cannot be undone.`
);
if (!confirmed) return;
setLoading(true);
setError(null);
try {
await deleteUser(user.id);
onDeleted(); // Notify parent component
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete user');
} finally {
setLoading(false);
}
};
return (
<div>
<button
onClick={handleDelete}
disabled={loading}
style={{
backgroundColor: '#f44336',
color: 'white',
padding: '0.5rem 1rem',
border: 'none',
borderRadius: '4px',
cursor: loading ? 'not-allowed' : 'pointer'
}}
>
{loading ? 'Deleting...' : 'Delete User'}
</button>
{error && <p style={{ color: 'red' }}>{error}</p>}
</div>
);
};
Complete CRUD Example
Putting it all together in a user management component:
const UserManagement: React.FC = () => {
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(false);
const [editingUser, setEditingUser] = useState<User | null>(null);
// READ: Fetch all users
const fetchUsers = async () => {
setLoading(true);
try {
const response = await getUsers({ limit: 50 });
setUsers(response.data);
} catch (error) {
console.error('Failed to fetch users:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchUsers();
}, []);
// CREATE: Add new user
const handleCreate = async (userData: CreateUserData) => {
try {
const newUser = await createUser(userData);
setUsers(prev => [...prev, newUser]);
} catch (error) {
console.error('Failed to create user:', error);
}
};
// UPDATE: Edit existing user
const handleUpdate = async (userId: number, updates: UpdateUserData) => {
try {
const updatedUser = await updateUser(userId, updates);
setUsers(prev => prev.map(u => u.id === userId ? updatedUser : u));
setEditingUser(null);
} catch (error) {
console.error('Failed to update user:', error);
}
};
// DELETE: Remove user
const handleDelete = async (userId: number) => {
try {
await deleteUser(userId);
setUsers(prev => prev.filter(u => u.id !== userId));
} catch (error) {
console.error('Failed to delete user:', error);
}
};
if (loading) return <div>Loading users...</div>;
return (
<div>
<h2>User Management</h2>
{/* User list */}
<div>
{users.map(user => (
<div key={user.id} style={{
border: '1px solid #ddd',
padding: '1rem',
marginBottom: '0.5rem'
}}>
<h3>{user.name}</h3>
<p>{user.email} - {user.role}</p>
<button onClick={() => setEditingUser(user)}>
Edit
</button>
<button onClick={() => handleDelete(user.id)}>
Delete
</button>
</div>
))}
</div>
{/* Edit modal */}
{editingUser && (
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}>
<div style={{
backgroundColor: 'white',
padding: '2rem',
borderRadius: '8px'
}}>
<h3>Edit User</h3>
<EditUserForm
user={editingUser}
onUpdate={handleUpdate}
onCancel={() => setEditingUser(null)}
/>
</div>
</div>
)}
</div>
);
};
π‘ CRUD Best Practices
- Always confirm DELETE operations - Use confirmation dialogs for destructive actions
- Show loading states - Users need feedback during operations
- Handle errors gracefully - Display meaningful error messages
- Update UI optimistically - Update state immediately, rollback on failure
- Refresh list after mutations - Ensure data stays in sync
- Use PATCH for partial updates - Only send changed fields
- Validate on client and server - Client validation for UX, server for security
ποΈ Creating an API Service Layer
Instead of scattering API calls throughout your components, organize them in a dedicated service layer. This improves maintainability, testability, and reusability!
Why Use a Service Layer?
β Without Service Layer (Bad)
// Component A
const ComponentA = () => {
const fetchData = async () => {
const response = await fetch('https://api.example.com/api/users', {
headers: { 'Authorization': 'Bearer token123' }
});
// ...
};
};
// Component B (duplicated code!)
const ComponentB = () => {
const fetchData = async () => {
const response = await fetch('https://api.example.com/api/users', {
headers: { 'Authorization': 'Bearer token123' }
});
// ...
};
};
// Problems:
// β Code duplication
// β Hard to change API URL
// β Hard to add authentication
// β Hard to test
// β Hard to add error handling
β With Service Layer (Good)
// services/userService.ts (centralized)
export const userService = {
getUsers: () => { /* ... */ },
getUser: (id) => { /* ... */ },
createUser: (data) => { /* ... */ },
updateUser: (id, data) => { /* ... */ },
deleteUser: (id) => { /* ... */ }
};
// Component A
import { userService } from '@/services/userService';
const ComponentA = () => {
const fetchData = () => userService.getUsers();
};
// Component B (no duplication!)
import { userService } from '@/services/userService';
const ComponentB = () => {
const fetchData = () => userService.getUsers();
};
// Benefits:
// β
No duplication
// β
Single place to update
// β
Easy to add authentication
// β
Easy to test
// β
Consistent error handling
Building a User Service
β Complete User Service
// services/api/userService.ts
import { apiClient } from './apiClient'; // We'll build this next!
export interface User {
id: number;
name: string;
email: string;
role: 'user' | 'admin';
avatar?: string;
createdAt: string;
updatedAt: string;
}
export interface CreateUserData {
name: string;
email: string;
role?: 'user' | 'admin';
avatar?: string;
}
export interface UpdateUserData {
name?: string;
email?: string;
role?: 'user' | 'admin';
avatar?: string;
}
export interface GetUsersParams {
page?: number;
limit?: number;
role?: 'user' | 'admin';
search?: string;
sortBy?: string;
}
export interface PaginatedResponse<T> {
data: T[];
pagination: {
total: number;
page: number;
limit: number;
totalPages: number;
};
}
class UserService {
private baseUrl = '/users';
async getUsers(params?: GetUsersParams): Promise<PaginatedResponse<User>> {
const queryParams = new URLSearchParams();
if (params?.page) queryParams.append('page', params.page.toString());
if (params?.limit) queryParams.append('limit', params.limit.toString());
if (params?.role) queryParams.append('role', params.role);
if (params?.search) queryParams.append('q', params.search);
if (params?.sortBy) queryParams.append('sort', params.sortBy);
const query = queryParams.toString();
const url = `${this.baseUrl}${query ? `?${query}` : ''}`;
return apiClient.get<PaginatedResponse<User>>(url);
}
async getUser(id: number): Promise<User> {
return apiClient.get<User>(`${this.baseUrl}/${id}`);
}
async createUser(data: CreateUserData): Promise<User> {
return apiClient.post<User>(this.baseUrl, data);
}
async updateUser(id: number, data: UpdateUserData): Promise<User> {
return apiClient.patch<User>(`${this.baseUrl}/${id}`, data);
}
async deleteUser(id: number): Promise<void> {
return apiClient.delete(`${this.baseUrl}/${id}`);
}
// Additional methods
async getUserPosts(userId: number): Promise<Post[]> {
return apiClient.get<Post[]>(`${this.baseUrl}/${userId}/posts`);
}
async uploadAvatar(userId: number, file: File): Promise<User> {
const formData = new FormData();
formData.append('avatar', file);
return apiClient.post<User>(
`${this.baseUrl}/${userId}/avatar`,
formData,
{
headers: {
'Content-Type': 'multipart/form-data'
}
}
);
}
}
// Export singleton instance
export const userService = new UserService();
Building an API Client
Create a base API client that all services use:
β Reusable API Client
// services/api/apiClient.ts
export interface ApiClientConfig {
baseURL: string;
headers?: Record<string, string>;
timeout?: number;
}
export interface RequestConfig {
headers?: Record<string, string>;
params?: Record<string, any>;
}
class ApiClient {
private baseURL: string;
private defaultHeaders: Record<string, string>;
private timeout: number;
constructor(config: ApiClientConfig) {
this.baseURL = config.baseURL;
this.defaultHeaders = config.headers || {
'Content-Type': 'application/json',
'Accept': 'application/json'
};
this.timeout = config.timeout || 30000; // 30 seconds
}
// Set authorization token
setAuthToken(token: string) {
this.defaultHeaders['Authorization'] = `Bearer ${token}`;
}
// Remove authorization token
clearAuthToken() {
delete this.defaultHeaders['Authorization'];
}
// Build full URL
private buildURL(endpoint: string): string {
return `${this.baseURL}${endpoint}`;
}
// Merge headers
private mergeHeaders(customHeaders?: Record<string, string>): HeadersInit {
return {
...this.defaultHeaders,
...customHeaders
};
}
// Generic request method
private async request<T>(
method: string,
endpoint: string,
data?: any,
config?: RequestConfig
): Promise<T> {
const url = this.buildURL(endpoint);
const headers = this.mergeHeaders(config?.headers);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
try {
const options: RequestInit = {
method,
headers,
signal: controller.signal
};
// Add body for POST, PUT, PATCH
if (data && ['POST', 'PUT', 'PATCH'].includes(method)) {
if (data instanceof FormData) {
// Don't set Content-Type for FormData (browser sets it with boundary)
delete (options.headers as any)['Content-Type'];
options.body = data;
} else {
options.body = JSON.stringify(data);
}
}
const response = await fetch(url, options);
clearTimeout(timeoutId);
// Handle different status codes
if (!response.ok) {
await this.handleError(response);
}
// Handle 204 No Content
if (response.status === 204) {
return undefined as any;
}
// Parse JSON response
return await response.json();
} catch (error) {
clearTimeout(timeoutId);
if (error instanceof Error && error.name === 'AbortError') {
throw new Error('Request timeout');
}
throw error;
}
}
// Error handling
private async handleError(response: Response): Promise<never> {
let errorMessage = `HTTP ${response.status}: ${response.statusText}`;
try {
const errorData = await response.json();
errorMessage = errorData.message || errorMessage;
if (errorData.errors) {
// Validation errors
const errors = Object.entries(errorData.errors)
.map(([field, messages]) => `${field}: ${(messages as string[]).join(', ')}`)
.join('; ');
errorMessage += ` - ${errors}`;
}
} catch {
// Response body is not JSON
}
throw new Error(errorMessage);
}
// HTTP methods
async get<T>(endpoint: string, config?: RequestConfig): Promise<T> {
return this.request<T>('GET', endpoint, undefined, config);
}
async post<T>(endpoint: string, data?: any, config?: RequestConfig): Promise<T> {
return this.request<T>('POST', endpoint, data, config);
}
async put<T>(endpoint: string, data?: any, config?: RequestConfig): Promise<T> {
return this.request<T>('PUT', endpoint, data, config);
}
async patch<T>(endpoint: string, data?: any, config?: RequestConfig): Promise<T> {
return this.request<T>('PATCH', endpoint, data, config);
}
async delete<T>(endpoint: string, config?: RequestConfig): Promise<T> {
return this.request<T>('DELETE', endpoint, undefined, config);
}
}
// Create and export default instance
export const apiClient = new ApiClient({
baseURL: import.meta.env.VITE_API_URL || 'https://api.example.com/api',
timeout: 30000
});
Using the Service Layer
// In your components - clean and simple!
import { userService } from '@/services/api/userService';
const UserProfile: React.FC<{ userId: number }> = ({ userId }) => {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchUser = async () => {
try {
const userData = await userService.getUser(userId);
setUser(userData);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load user');
} finally {
setLoading(false);
}
};
fetchUser();
}, [userId]);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
if (!user) return null;
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
<p>Role: {user.role}</p>
</div>
);
};
Organizing Multiple Services
// services/api/index.ts
export { userService } from './userService';
export { postService } from './postService';
export { authService } from './authService';
export { commentService } from './commentService';
// Now import everything from one place!
import { userService, postService, authService } from '@/services/api';
β Service Layer Benefits
- Centralized API calls - All API logic in one place
- No duplication - Write once, use everywhere
- Easy to test - Mock services instead of fetch
- Easy to update - Change URL or auth in one place
- Type safety - TypeScript interfaces for all requests/responses
- Consistent error handling - Handle errors uniformly
- Reusable client - Share logic across services
π Authentication and Authorization
Most APIs require authentication to identify users and authorization to control what they can access. Let's implement JWT-based authentication, the most common pattern for modern web apps!
π Definitions
Authentication: Verifying who you are (proving identity with username/password, proving you're logged in with a token)
Authorization: Verifying what you can do (checking permissions, roles, access levels)
JWT (JSON Web Token): A secure, compact token format for transmitting information between parties. Contains user info and is signed to prevent tampering.
Authentication Flow
π Interactive JWT Authentication Flow
Click "Start Flow" to see how JWT authentication works step by step
π JWT Authentication verifies user identity using tokens. The token contains encoded user data and is sent with every request to prove authentication.
Building an Auth Service
β Complete Auth Service
// services/api/authService.ts
import { apiClient } from './apiClient';
export interface LoginCredentials {
email: string;
password: string;
}
export interface RegisterData {
name: string;
email: string;
password: string;
passwordConfirmation: string;
}
export interface AuthResponse {
token: string;
user: {
id: number;
name: string;
email: string;
role: 'user' | 'admin';
};
}
export interface User {
id: number;
name: string;
email: string;
role: 'user' | 'admin';
}
const TOKEN_KEY = 'auth_token';
const USER_KEY = 'auth_user';
class AuthService {
// Login
async login(credentials: LoginCredentials): Promise<AuthResponse> {
const response = await apiClient.post<AuthResponse>(
'/auth/login',
credentials
);
// Store token and user
this.setToken(response.token);
this.setUser(response.user);
// Set token in API client for future requests
apiClient.setAuthToken(response.token);
return response;
}
// Register
async register(data: RegisterData): Promise<AuthResponse> {
const response = await apiClient.post<AuthResponse>(
'/auth/register',
data
);
// Store token and user
this.setToken(response.token);
this.setUser(response.user);
// Set token in API client
apiClient.setAuthToken(response.token);
return response;
}
// Logout
async logout(): Promise<void> {
try {
// Call logout endpoint (optional - invalidates token on server)
await apiClient.post('/auth/logout');
} catch (error) {
console.error('Logout request failed:', error);
} finally {
// Always clear local data
this.clearAuth();
}
}
// Get current user
async getCurrentUser(): Promise<User> {
return apiClient.get<User>('/auth/me');
}
// Refresh token
async refreshToken(): Promise<string> {
const response = await apiClient.post<{ token: string }>('/auth/refresh');
this.setToken(response.token);
apiClient.setAuthToken(response.token);
return response.token;
}
// Token management
setToken(token: string): void {
localStorage.setItem(TOKEN_KEY, token);
}
getToken(): string | null {
return localStorage.getItem(TOKEN_KEY);
}
// User management
setUser(user: User): void {
localStorage.setItem(USER_KEY, JSON.stringify(user));
}
getUser(): User | null {
const userStr = localStorage.getItem(USER_KEY);
if (!userStr) return null;
try {
return JSON.parse(userStr);
} catch {
return null;
}
}
// Check if authenticated
isAuthenticated(): boolean {
return this.getToken() !== null;
}
// Clear auth data
clearAuth(): void {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
apiClient.clearAuthToken();
}
// Initialize (call on app startup)
initialize(): void {
const token = this.getToken();
if (token) {
apiClient.setAuthToken(token);
}
}
}
export const authService = new AuthService();
Login Component
import { authService } from '@/services/api/authService';
const LoginForm: React.FC = () => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const navigate = useNavigate(); // From react-router-dom
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const credentials = {
email: formData.get('email') as string,
password: formData.get('password') as string,
};
setLoading(true);
setError(null);
try {
await authService.login(credentials);
// Redirect to dashboard on success
navigate('/dashboard');
} catch (err) {
setError(
err instanceof Error
? err.message
: 'Login failed. Please check your credentials.'
);
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handleSubmit}>
<h2>Login</h2>
{error && (
<div style={{
backgroundColor: '#ffebee',
color: '#c62828',
padding: '1rem',
borderRadius: '4px',
marginBottom: '1rem'
}}>
{error}
</div>
)}
<div style={{ marginBottom: '1rem' }}>
<label htmlFor="email">Email</label>
<input
id="email"
name="email"
type="email"
required
placeholder="your@email.com"
style={{ width: '100%', padding: '0.5rem' }}
/>
</div>
<div style={{ marginBottom: '1rem' }}>
<label htmlFor="password">Password</label>
<input
id="password"
name="password"
type="password"
required
placeholder="β’β’β’β’β’β’β’β’"
style={{ width: '100%', padding: '0.5rem' }}
/>
</div>
<button
type="submit"
disabled={loading}
style={{
width: '100%',
padding: '0.75rem',
backgroundColor: '#667eea',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: loading ? 'not-allowed' : 'pointer'
}}
>
{loading ? 'Logging in...' : 'Login'}
</button>
</form>
);
};
Protected Routes
Prevent unauthenticated users from accessing protected pages:
// components/ProtectedRoute.tsx
import { Navigate } from 'react-router-dom';
import { authService } from '@/services/api/authService';
interface ProtectedRouteProps {
children: React.ReactNode;
requiredRole?: 'user' | 'admin';
}
const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
children,
requiredRole
}) => {
const isAuthenticated = authService.isAuthenticated();
if (!isAuthenticated) {
// Redirect to login if not authenticated
return <Navigate to="/login" replace />;
}
// Check role if required
if (requiredRole) {
const user = authService.getUser();
if (!user || user.role !== requiredRole) {
// Redirect to unauthorized page
return <Navigate to="/unauthorized" replace />;
}
}
return <>{children}</>;
};
// Usage in App.tsx
import { BrowserRouter, Routes, Route } from 'react-router-dom';
function App() {
// Initialize auth on app startup
useEffect(() => {
authService.initialize();
}, []);
return (
<BrowserRouter>
<Routes>
{/* Public routes */}
<Route path="/login" element={<LoginForm />} />
<Route path="/register" element={<RegisterForm />} />
{/* Protected routes - any authenticated user */}
<Route
path="/dashboard"
element={
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
}
/>
{/* Protected routes - admin only */}
<Route
path="/admin"
element={
<ProtectedRoute requiredRole="admin">
<AdminPanel />
</ProtectedRoute>
}
/>
</Routes>
</BrowserRouter>
);
}
Auto-Refreshing Tokens
Handle token expiration automatically:
// Enhance API Client with token refresh
class ApiClient {
// ... existing code ...
private async request<T>(
method: string,
endpoint: string,
data?: any,
config?: RequestConfig
): Promise<T> {
try {
// Make request
const response = await fetch(url, options);
// If 401 Unauthorized, try to refresh token
if (response.status === 401) {
try {
// Attempt token refresh
await authService.refreshToken();
// Retry original request with new token
const retryResponse = await fetch(url, {
...options,
headers: this.mergeHeaders(config?.headers)
});
if (!retryResponse.ok) {
await this.handleError(retryResponse);
}
return await retryResponse.json();
} catch (refreshError) {
// Refresh failed - logout user
authService.clearAuth();
window.location.href = '/login';
throw new Error('Session expired. Please login again.');
}
}
if (!response.ok) {
await this.handleError(response);
}
return await response.json();
} catch (error) {
throw error;
}
}
}
Using Auth Context
Provide auth state globally with React Context:
β Auth Context Provider
// contexts/AuthContext.tsx
import { createContext, useContext, useState, useEffect } from 'react';
import { authService, User } from '@/services/api/authService';
interface AuthContextType {
user: User | null;
isAuthenticated: boolean;
isLoading: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
refreshUser: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
// Initialize - load user from localStorage
useEffect(() => {
const initAuth = async () => {
authService.initialize();
const storedUser = authService.getUser();
if (storedUser && authService.isAuthenticated()) {
try {
// Verify token is still valid by fetching current user
const currentUser = await authService.getCurrentUser();
setUser(currentUser);
} catch (error) {
// Token invalid - clear auth
authService.clearAuth();
}
}
setIsLoading(false);
};
initAuth();
}, []);
const login = async (email: string, password: string) => {
const response = await authService.login({ email, password });
setUser(response.user);
};
const logout = async () => {
await authService.logout();
setUser(null);
};
const refreshUser = async () => {
const currentUser = await authService.getCurrentUser();
setUser(currentUser);
};
return (
<AuthContext.Provider
value={{
user,
isAuthenticated: !!user,
isLoading,
login,
logout,
refreshUser
}}
>
{children}
</AuthContext.Provider>
);
};
// Custom hook
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within AuthProvider');
}
return context;
};
// Usage in App.tsx
function App() {
return (
<AuthProvider>
<BrowserRouter>
<Routes>
{/* routes */}
</Routes>
</BrowserRouter>
</AuthProvider>
);
}
// Usage in components
const UserProfile: React.FC = () => {
const { user, logout } = useAuth();
if (!user) return null;
return (
<div>
<h2>Welcome, {user.name}!</h2>
<p>Email: {user.email}</p>
<p>Role: {user.role}</p>
<button onClick={logout}>Logout</button>
</div>
);
};
π‘ Authentication Best Practices
- Store tokens securely - localStorage for SPAs, httpOnly cookies for SSR
- Never store passwords - Only send them during login/register
- Handle token expiration - Auto-refresh or redirect to login
- Clear auth on logout - Remove tokens and user data
- Validate on every request - Server should verify tokens
- Use HTTPS in production - Never send tokens over HTTP
- Implement role-based access - Check permissions on client AND server
- Show loading states - While checking authentication status
π¨ Error Handling Strategies
Things go wrong: network fails, servers crash, users lose internet. Proper error handling is crucial for good UX. Let's build a robust error handling system!
π¦ Error Handling Decision Tree
Click on each error type to see the recommended handling strategy
π Click on any error type to see detailed handling strategies and code examples.
Types of API Errors
| Error Type | Cause | Example | How to Handle |
|---|---|---|---|
| Network Error | No internet, DNS failure | fetch() rejects | Show "offline" message, retry button |
| Timeout | Request takes too long | AbortController aborts | Show timeout message, retry option |
| 4xx Client Error | Bad request, unauthorized | 400, 401, 403, 404, 422 | Show specific error message |
| 5xx Server Error | Server crashed, overloaded | 500, 502, 503 | Show generic message, retry |
| Validation Error | Invalid form data | 422 with field errors | Show field-specific errors |
Enhanced API Client with Error Handling
β Comprehensive Error Handling
// types/errors.ts
export class ApiError extends Error {
statusCode: number;
errors?: Record<string, string[]>; // Validation errors
constructor(message: string, statusCode: number, errors?: Record<string, string[]>) {
super(message);
this.name = 'ApiError';
this.statusCode = statusCode;
this.errors = errors;
}
isClientError(): boolean {
return this.statusCode >= 400 && this.statusCode < 500;
}
isServerError(): boolean {
return this.statusCode >= 500;
}
isValidationError(): boolean {
return this.statusCode === 422 && !!this.errors;
}
}
export class NetworkError extends Error {
constructor(message: string = 'Network error. Please check your internet connection.') {
super(message);
this.name = 'NetworkError';
}
}
export class TimeoutError extends Error {
constructor(message: string = 'Request timeout. Please try again.') {
super(message);
this.name = 'TimeoutError';
}
}
// Enhanced API Client
class ApiClient {
// ... existing code ...
private async handleError(response: Response): Promise<never> {
let errorMessage = `HTTP ${response.status}: ${response.statusText}`;
let errors: Record<string, string[]> | undefined;
try {
const errorData = await response.json();
errorMessage = errorData.message || errorMessage;
// Extract validation errors
if (errorData.errors) {
errors = errorData.errors;
}
} catch {
// Response body is not JSON or empty
}
throw new ApiError(errorMessage, response.status, errors);
}
private async request<T>(
method: string,
endpoint: string,
data?: any,
config?: RequestConfig
): Promise<T> {
const url = this.buildURL(endpoint);
const headers = this.mergeHeaders(config?.headers);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
try {
const options: RequestInit = {
method,
headers,
signal: controller.signal
};
if (data && ['POST', 'PUT', 'PATCH'].includes(method)) {
options.body = JSON.stringify(data);
}
const response = await fetch(url, options);
clearTimeout(timeoutId);
if (!response.ok) {
await this.handleError(response);
}
if (response.status === 204) {
return undefined as any;
}
return await response.json();
} catch (error) {
clearTimeout(timeoutId);
// Handle different error types
if (error instanceof Error) {
if (error.name === 'AbortError') {
throw new TimeoutError();
}
// Network errors (no response from server)
if (error.message.includes('Failed to fetch') ||
error.message.includes('Network request failed')) {
throw new NetworkError();
}
}
// Re-throw API errors and unknown errors
throw error;
}
}
}
Error Display Component
// components/ErrorDisplay.tsx
import { ApiError, NetworkError, TimeoutError } from '@/types/errors';
interface ErrorDisplayProps {
error: Error | null;
onRetry?: () => void;
}
const ErrorDisplay: React.FC<ErrorDisplayProps> = ({ error, onRetry }) => {
if (!error) return null;
// Network error
if (error instanceof NetworkError) {
return (
<div style={{
backgroundColor: '#fff3cd',
border: '1px solid #ffc107',
borderRadius: '4px',
padding: '1rem',
marginBottom: '1rem'
}}>
<h4 style={{ marginTop: 0 }}>π‘ Connection Error</h4>
<p>{error.message}</p>
{onRetry && (
<button onClick={onRetry}>Retry</button>
)}
</div>
);
}
// Timeout error
if (error instanceof TimeoutError) {
return (
<div style={{
backgroundColor: '#fff3cd',
border: '1px solid #ffc107',
borderRadius: '4px',
padding: '1rem',
marginBottom: '1rem'
}}>
<h4 style={{ marginTop: 0 }}>β±οΈ Request Timeout</h4>
<p>{error.message}</p>
{onRetry && (
<button onClick={onRetry}>Try Again</button>
)}
</div>
);
}
// API error with validation errors
if (error instanceof ApiError && error.isValidationError() && error.errors) {
return (
<div style={{
backgroundColor: '#ffebee',
border: '1px solid #f44336',
borderRadius: '4px',
padding: '1rem',
marginBottom: '1rem'
}}>
<h4 style={{ marginTop: 0 }}>β Validation Errors</h4>
<ul style={{ margin: 0 }}>
{Object.entries(error.errors).map(([field, messages]) => (
<li key={field}>
<strong>{field}:</strong> {messages.join(', ')}
</li>
))}
</ul>
</div>
);
}
// Server error (5xx)
if (error instanceof ApiError && error.isServerError()) {
return (
<div style={{
backgroundColor: '#ffebee',
border: '1px solid #f44336',
borderRadius: '4px',
padding: '1rem',
marginBottom: '1rem'
}}>
<h4 style={{ marginTop: 0 }}>π§ Server Error</h4>
<p>Something went wrong on our end. Please try again later.</p>
{onRetry && (
<button onClick={onRetry}>Retry</button>
)}
</div>
);
}
// Generic error
return (
<div style={{
backgroundColor: '#ffebee',
border: '1px solid #f44336',
borderRadius: '4px',
padding: '1rem',
marginBottom: '1rem'
}}>
<h4 style={{ marginTop: 0 }}>β Error</h4>
<p>{error.message}</p>
{onRetry && (
<button onClick={onRetry}>Retry</button>
)}
</div>
);
};
// Usage
const UserList: React.FC = () => {
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const fetchUsers = async () => {
setLoading(true);
setError(null);
try {
const response = await userService.getUsers();
setUsers(response.data);
} catch (err) {
setError(err as Error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchUsers();
}, []);
return (
<div>
<ErrorDisplay error={error} onRetry={fetchUsers} />
{loading && <div>Loading...</div>}
{users.map(user => (
<div key={user.id}>{user.name}</div>
))}
</div>
);
};
Retry Logic
Automatically retry failed requests:
// utils/retry.ts
interface RetryOptions {
maxAttempts?: number;
delay?: number;
backoff?: boolean; // Exponential backoff
}
export async function withRetry<T>(
fn: () => Promise<T>,
options: RetryOptions = {}
): Promise<T> {
const { maxAttempts = 3, delay = 1000, backoff = true } = options;
let lastError: Error;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error as Error;
// Don't retry client errors (4xx) or validation errors
if (error instanceof ApiError && error.isClientError()) {
throw error;
}
// If not last attempt, wait and retry
if (attempt < maxAttempts) {
const waitTime = backoff ? delay * Math.pow(2, attempt - 1) : delay;
await new Promise(resolve => setTimeout(resolve, waitTime));
}
}
}
throw lastError!;
}
// Usage
const fetchUserWithRetry = async (userId: number) => {
return withRetry(
() => userService.getUser(userId),
{ maxAttempts: 3, delay: 1000, backoff: true }
);
};
β Error Handling Best Practices
- Use specific error types - Different errors need different handling
- Show user-friendly messages - "Network error" not "fetch failed"
- Provide retry options - Let users try again after failures
- Log errors properly - Send to error tracking (Sentry, LogRocket)
- Don't retry client errors - 4xx errors won't succeed on retry
- Use exponential backoff - Don't hammer failing servers
- Handle validation errors specially - Show field-specific errors
- Test error scenarios - Simulate network failures, timeouts, etc.
π TypeScript for APIs
TypeScript's type system shines when working with APIs. Proper typing catches bugs at compile-time, provides autocomplete, and makes your code self-documenting. Let's master TypeScript for APIs!
Typing API Responses
β Best Practices for Response Types
// types/api/user.ts
// Domain model - the core data type
export interface User {
id: number;
name: string;
email: string;
role: 'user' | 'admin' | 'moderator';
avatar?: string;
bio?: string;
createdAt: string; // ISO date string from API
updatedAt: string;
}
// API response wrapper
export interface ApiResponse<T> {
data: T;
message?: string;
meta?: {
timestamp: string;
version: string;
};
}
// Paginated response
export interface PaginatedResponse<T> {
data: T[];
pagination: {
total: number;
page: number;
limit: number;
totalPages: number;
hasNext: boolean;
hasPrevious: boolean;
};
}
// List response (no pagination)
export interface ListResponse<T> {
data: T[];
count: number;
}
// Input types - what we send to API
export interface CreateUserInput {
name: string;
email: string;
password: string;
role?: 'user' | 'admin';
}
export interface UpdateUserInput {
name?: string;
email?: string;
bio?: string;
avatar?: string;
}
// Query parameters
export interface GetUsersParams {
page?: number;
limit?: number;
role?: 'user' | 'admin' | 'moderator';
search?: string;
sortBy?: 'name' | 'email' | 'createdAt';
sortOrder?: 'asc' | 'desc';
}
Generic API Methods
// Fully typed service methods
class UserService {
async getUsers(
params?: GetUsersParams
): Promise<PaginatedResponse<User>> {
return apiClient.get<PaginatedResponse<User>>('/users', { params });
}
async getUser(id: number): Promise<User> {
// ApiResponse wrapper is unpacked
const response = await apiClient.get<ApiResponse<User>>(`/users/${id}`);
return response.data;
}
async createUser(input: CreateUserInput): Promise<User> {
const response = await apiClient.post<ApiResponse<User>>('/users', input);
return response.data;
}
async updateUser(id: number, input: UpdateUserInput): Promise<User> {
const response = await apiClient.patch<ApiResponse<User>>(`/users/${id}`, input);
return response.data;
}
async deleteUser(id: number): Promise<void> {
await apiClient.delete(`/users/${id}`);
}
}
// Usage in components - TypeScript knows all the types!
const UserProfile: React.FC<{ userId: number }> = ({ userId }) => {
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
userService.getUser(userId).then(setUser);
}, [userId]);
// TypeScript knows user.name, user.email, etc.
return user && <h2>{user.name}</h2>;
};
Utility Types for APIs
// Omit sensitive fields
type PublicUser = Omit<User, 'email' | 'role'>;
// Pick specific fields
type UserPreview = Pick<User, 'id' | 'name' | 'avatar'>;
// Make all fields optional for PATCH
type PartialUser = Partial<User>;
// Make all fields required
type CompleteUser = Required<User>;
// Make all fields readonly
type ImmutableUser = Readonly<User>;
// Custom mapped types
type WithTimestamps<T> = T & {
createdAt: string;
updatedAt: string;
};
type UserWithTimestamps = WithTimestamps<{
id: number;
name: string;
}>;
Discriminated Unions for API States
β Type-Safe Async State
// Better than separate loading/error/data states!
type AsyncState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error };
// Usage
const UserProfile: React.FC<{ userId: number }> = ({ userId }) => {
const [state, setState] = useState<AsyncState<User>>({ status: 'idle' });
useEffect(() => {
setState({ status: 'loading' });
userService.getUser(userId)
.then(data => setState({ status: 'success', data }))
.catch(error => setState({ status: 'error', error }));
}, [userId]);
// TypeScript enforces proper handling of each state
switch (state.status) {
case 'idle':
case 'loading':
return <div>Loading...</div>;
case 'success':
// TypeScript knows state.data exists here!
return <h2>{state.data.name}</h2>;
case 'error':
// TypeScript knows state.error exists here!
return <div>Error: {state.error.message}</div>;
default:
// TypeScript ensures we've handled all cases
const exhaustiveCheck: never = state;
return exhaustiveCheck;
}
};
Type Guards for API Data
// Type guard to check if user is admin
function isAdmin(user: User): user is User & { role: 'admin' } {
return user.role === 'admin';
}
// Usage
const user = await userService.getUser(123);
if (isAdmin(user)) {
// TypeScript knows user.role === 'admin' here
console.log('Admin user:', user.name);
}
// Runtime validation with Zod (recommended!)
import { z } from 'zod';
const UserSchema = z.object({
id: z.number(),
name: z.string().min(1),
email: z.string().email(),
role: z.enum(['user', 'admin', 'moderator']),
avatar: z.string().url().optional(),
bio: z.string().optional(),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
});
// Infer TypeScript type from schema
type User = z.infer<typeof UserSchema>;
// Validate API responses at runtime
async function getUser(id: number): Promise<User> {
const response = await apiClient.get(`/users/${id}`);
// This will throw if response doesn't match schema!
return UserSchema.parse(response.data);
}
π‘ TypeScript API Tips
- Define types for everything - Requests, responses, params, errors
- Use discriminated unions - Better than separate boolean flags
- Leverage utility types - Partial, Pick, Omit save time
- Validate at runtime - TypeScript types disappear at runtime; use Zod
- Keep types close to usage - Organize by feature/domain
- Export types - Share types between services and components
- Use enums sparingly - Prefer union types for flexibility
π Environment Variables
Never hardcode API URLs, keys, or secrets! Environment variables let you configure your app for different environments (development, staging, production) without changing code.
Setting Up Environment Variables
β Vite Environment Variables
# .env.development
VITE_API_URL=http://localhost:3000/api
VITE_API_TIMEOUT=30000
VITE_ENABLE_LOGGING=true
# .env.production
VITE_API_URL=https://api.production.com/api
VITE_API_TIMEOUT=30000
VITE_ENABLE_LOGGING=false
# .env.local (ignored by git, for local overrides)
VITE_API_URL=http://192.168.1.100:3000/api
β οΈ Important Rules
- Prefix with VITE_ - Required for Vite to expose variables to client
- Never commit secrets - Add .env.local to .gitignore
- No sensitive data - These are visible in browser! No API keys, passwords
- Use .env.example - Commit a template showing required variables
Using Environment Variables
// vite-env.d.ts - Type definitions
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string;
readonly VITE_API_TIMEOUT: string;
readonly VITE_ENABLE_LOGGING: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
// config/env.ts - Centralized config
export const config = {
apiUrl: import.meta.env.VITE_API_URL || 'http://localhost:3000/api',
apiTimeout: parseInt(import.meta.env.VITE_API_TIMEOUT) || 30000,
enableLogging: import.meta.env.VITE_ENABLE_LOGGING === 'true',
isDevelopment: import.meta.env.DEV,
isProduction: import.meta.env.PROD,
};
// services/api/apiClient.ts - Use in API client
import { config } from '@/config/env';
export const apiClient = new ApiClient({
baseURL: config.apiUrl,
timeout: config.apiTimeout,
});
// utils/logger.ts - Conditional logging
import { config } from '@/config/env';
export function log(...args: any[]) {
if (config.enableLogging) {
console.log('[API]', ...args);
}
}
Example .env Files
# .env.example (commit this!)
# API Configuration
VITE_API_URL=
VITE_API_TIMEOUT=30000
# Feature Flags
VITE_ENABLE_LOGGING=false
VITE_ENABLE_ANALYTICS=false
# Third-party Services (public keys only!)
VITE_STRIPE_PUBLIC_KEY=
VITE_GOOGLE_MAPS_KEY=
# .gitignore
# Environment files
.env.local
.env.*.local
# Keep these:
# .env
# .env.development
# .env.production
# .env.example
β Environment Variables Best Practices
- Use for configuration only - URLs, timeouts, feature flags
- Never store secrets - Client-side code is public!
- Provide defaults - Use || operator for fallback values
- Validate on startup - Check required vars are present
- Document in .env.example - Team knows what's needed
- Use different values per environment - Dev/staging/prod
- Centralize access - Import from config file, not scattered
π οΈ Building an API Client
Let's put everything together and build a production-ready API client with all the features we've learned!
β Complete Production API Client
// services/api/apiClient.ts
import { config } from '@/config/env';
import { ApiError, NetworkError, TimeoutError } from '@/types/errors';
interface ApiClientConfig {
baseURL: string;
timeout?: number;
headers?: Record<string, string>;
}
interface RequestConfig {
headers?: Record<string, string>;
params?: Record<string, any>;
}
// Request/Response interceptors
type RequestInterceptor = (config: RequestConfig) => RequestConfig | Promise<RequestConfig>;
type ResponseInterceptor = (response: any) => any | Promise<any>;
type ErrorInterceptor = (error: Error) => Error | Promise<Error>;
class ApiClient {
private baseURL: string;
private timeout: number;
private defaultHeaders: Record<string, string>;
private requestInterceptors: RequestInterceptor[] = [];
private responseInterceptors: ResponseInterceptor[] = [];
private errorInterceptors: ErrorInterceptor[] = [];
constructor(config: ApiClientConfig) {
this.baseURL = config.baseURL;
this.timeout = config.timeout || 30000;
this.defaultHeaders = {
'Content-Type': 'application/json',
'Accept': 'application/json',
...config.headers,
};
}
// Interceptor management
addRequestInterceptor(interceptor: RequestInterceptor) {
this.requestInterceptors.push(interceptor);
}
addResponseInterceptor(interceptor: ResponseInterceptor) {
this.responseInterceptors.push(interceptor);
}
addErrorInterceptor(interceptor: ErrorInterceptor) {
this.errorInterceptors.push(interceptor);
}
// Auth token management
setAuthToken(token: string) {
this.defaultHeaders['Authorization'] = `Bearer ${token}`;
}
clearAuthToken() {
delete this.defaultHeaders['Authorization'];
}
// Build full URL
private buildURL(endpoint: string, params?: Record<string, any>): string {
const url = `${this.baseURL}${endpoint}`;
if (params) {
const searchParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
searchParams.append(key, String(value));
}
});
const queryString = searchParams.toString();
return queryString ? `${url}?${queryString}` : url;
}
return url;
}
// Merge headers
private mergeHeaders(customHeaders?: Record<string, string>): HeadersInit {
return { ...this.defaultHeaders, ...customHeaders };
}
// Apply request interceptors
private async applyRequestInterceptors(config: RequestConfig): Promise<RequestConfig> {
let finalConfig = config;
for (const interceptor of this.requestInterceptors) {
finalConfig = await interceptor(finalConfig);
}
return finalConfig;
}
// Apply response interceptors
private async applyResponseInterceptors(response: any): Promise<any> {
let finalResponse = response;
for (const interceptor of this.responseInterceptors) {
finalResponse = await interceptor(finalResponse);
}
return finalResponse;
}
// Apply error interceptors
private async applyErrorInterceptors(error: Error): Promise<Error> {
let finalError = error;
for (const interceptor of this.errorInterceptors) {
finalError = await interceptor(finalError);
}
return finalError;
}
// Handle errors
private async handleError(response: Response): Promise<never> {
let errorMessage = `HTTP ${response.status}: ${response.statusText}`;
let errors: Record<string, string[]> | undefined;
try {
const errorData = await response.json();
errorMessage = errorData.message || errorMessage;
errors = errorData.errors;
} catch {
// Response body is not JSON
}
throw new ApiError(errorMessage, response.status, errors);
}
// Generic request method
private async request<T>(
method: string,
endpoint: string,
data?: any,
config?: RequestConfig
): Promise<T> {
// Apply request interceptors
const interceptedConfig = await this.applyRequestInterceptors(config || {});
const url = this.buildURL(endpoint, interceptedConfig.params);
const headers = this.mergeHeaders(interceptedConfig.headers);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
try {
const options: RequestInit = {
method,
headers,
signal: controller.signal,
};
// Add body for POST, PUT, PATCH
if (data && ['POST', 'PUT', 'PATCH'].includes(method)) {
if (data instanceof FormData) {
delete (options.headers as any)['Content-Type'];
options.body = data;
} else {
options.body = JSON.stringify(data);
}
}
if (config.enableLogging) {
console.log(`[API] ${method} ${url}`, data);
}
const response = await fetch(url, options);
clearTimeout(timeoutId);
if (!response.ok) {
await this.handleError(response);
}
// Handle 204 No Content
if (response.status === 204) {
return undefined as any;
}
const result = await response.json();
// Apply response interceptors
const interceptedResult = await this.applyResponseInterceptors(result);
return interceptedResult;
} catch (error) {
clearTimeout(timeoutId);
let finalError = error as Error;
// Handle timeout
if (finalError.name === 'AbortError') {
finalError = new TimeoutError();
}
// Handle network errors
if (finalError.message?.includes('Failed to fetch')) {
finalError = new NetworkError();
}
// Apply error interceptors
finalError = await this.applyErrorInterceptors(finalError);
throw finalError;
}
}
// HTTP methods
async get<T>(endpoint: string, config?: RequestConfig): Promise<T> {
return this.request<T>('GET', endpoint, undefined, config);
}
async post<T>(endpoint: string, data?: any, config?: RequestConfig): Promise<T> {
return this.request<T>('POST', endpoint, data, config);
}
async put<T>(endpoint: string, data?: any, config?: RequestConfig): Promise<T> {
return this.request<T>('PUT', endpoint, data, config);
}
async patch<T>(endpoint: string, data?: any, config?: RequestConfig): Promise<T> {
return this.request<T>('PATCH', endpoint, data, config);
}
async delete<T>(endpoint: string, config?: RequestConfig): Promise<T> {
return this.request<T>('DELETE', endpoint, undefined, config);
}
}
// Create and export singleton
export const apiClient = new ApiClient({
baseURL: config.apiUrl,
timeout: config.apiTimeout,
});
// Add logging interceptor in development
if (config.isDevelopment) {
apiClient.addResponseInterceptor((response) => {
console.log('[API Response]', response);
return response;
});
apiClient.addErrorInterceptor((error) => {
console.error('[API Error]', error);
return error;
});
}
Using Interceptors
// Add request ID to all requests
apiClient.addRequestInterceptor((config) => {
return {
...config,
headers: {
...config.headers,
'X-Request-ID': crypto.randomUUID(),
},
};
});
// Transform all dates from strings to Date objects
apiClient.addResponseInterceptor((response) => {
if (response.createdAt) {
response.createdAt = new Date(response.createdAt);
}
if (response.updatedAt) {
response.updatedAt = new Date(response.updatedAt);
}
return response;
});
// Handle token refresh on 401
apiClient.addErrorInterceptor(async (error) => {
if (error instanceof ApiError && error.statusCode === 401) {
try {
await authService.refreshToken();
// Retry original request (you'd need to store it)
} catch {
authService.clearAuth();
window.location.href = '/login';
}
}
return error;
});
ποΈ Hands-on Practice
Time to apply everything you've learned! These exercises will solidify your API integration skills.
ποΈ Exercise 1: Build a Task Service
Create a complete service layer for a task management API.
Requirements:
- Define TypeScript interfaces for Task, CreateTaskInput, UpdateTaskInput
- Implement all CRUD operations (create, read, update, delete)
- Add methods for: getTasks (with pagination), getTasksByStatus, completeTask
- Use the API client we built
- Type everything properly
π‘ Hint
Start by defining your types, then create a TaskService class with methods that use apiClient.get/post/patch/delete. Each method should be async and return properly typed promises.
β Solution
// types/api/task.ts
export interface Task {
id: number;
title: string;
description: string;
status: 'todo' | 'in_progress' | 'done';
priority: 'low' | 'medium' | 'high';
dueDate?: string;
assigneeId?: number;
createdAt: string;
updatedAt: string;
}
export interface CreateTaskInput {
title: string;
description?: string;
status?: 'todo' | 'in_progress' | 'done';
priority?: 'low' | 'medium' | 'high';
dueDate?: string;
assigneeId?: number;
}
export interface UpdateTaskInput {
title?: string;
description?: string;
status?: 'todo' | 'in_progress' | 'done';
priority?: 'low' | 'medium' | 'high';
dueDate?: string;
assigneeId?: number;
}
export interface GetTasksParams {
page?: number;
limit?: number;
status?: 'todo' | 'in_progress' | 'done';
priority?: 'low' | 'medium' | 'high';
assigneeId?: number;
sortBy?: 'createdAt' | 'dueDate' | 'priority';
sortOrder?: 'asc' | 'desc';
}
// services/api/taskService.ts
import { apiClient } from './apiClient';
import { Task, CreateTaskInput, UpdateTaskInput, GetTasksParams } from '@/types/api/task';
import { PaginatedResponse } from '@/types/api';
class TaskService {
private baseUrl = '/tasks';
async getTasks(params?: GetTasksParams): Promise> {
return apiClient.get>(this.baseUrl, { params });
}
async getTask(id: number): Promise {
return apiClient.get(`${this.baseUrl}/${id}`);
}
async getTasksByStatus(status: 'todo' | 'in_progress' | 'done'): Promise {
const response = await apiClient.get<{ data: Task[] }>(
this.baseUrl,
{ params: { status } }
);
return response.data;
}
async createTask(input: CreateTaskInput): Promise {
return apiClient.post(this.baseUrl, input);
}
async updateTask(id: number, input: UpdateTaskInput): Promise {
return apiClient.patch(`${this.baseUrl}/${id}`, input);
}
async completeTask(id: number): Promise {
return apiClient.patch(`${this.baseUrl}/${id}`, { status: 'done' });
}
async deleteTask(id: number): Promise {
return apiClient.delete(`${this.baseUrl}/${id}`);
}
async assignTask(taskId: number, userId: number): Promise {
return apiClient.patch(`${this.baseUrl}/${taskId}`, { assigneeId: userId });
}
}
export const taskService = new TaskService();
ποΈ Exercise 2: Implement Authentication
Add authentication to your task app with login, register, and protected routes.
Requirements:
- Create login and register forms
- Store JWT token in localStorage
- Add token to all API requests
- Create ProtectedRoute component
- Handle token expiration (401 errors)
- Implement logout functionality
π‘ Hint
Use the authService pattern we built earlier. Initialize auth on app startup to restore sessions. Use an AuthContext to provide auth state globally.
β Solution
// App.tsx
import { useEffect } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { authService } from '@/services/api/authService';
import { LoginForm } from '@/components/LoginForm';
import { Dashboard } from '@/pages/Dashboard';
import { ProtectedRoute } from '@/components/ProtectedRoute';
function App() {
// Initialize auth on startup
useEffect(() => {
authService.initialize();
}, []);
return (
} />
}
/>
} />
);
}
// components/LoginForm.tsx
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { authService } from '@/services/api/authService';
export const LoginForm: React.FC = () => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const navigate = useNavigate();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
setLoading(true);
setError(null);
try {
await authService.login({
email: formData.get('email') as string,
password: formData.get('password') as string,
});
navigate('/dashboard');
} catch (err) {
setError(err instanceof Error ? err.message : 'Login failed');
} finally {
setLoading(false);
}
};
return (
);
};
// components/ProtectedRoute.tsx
import { Navigate } from 'react-router-dom';
import { authService } from '@/services/api/authService';
interface ProtectedRouteProps {
children: React.ReactNode;
}
export const ProtectedRoute: React.FC = ({ children }) => {
if (!authService.isAuthenticated()) {
return ;
}
return <>{children}>;
};
ποΈ Exercise 3: Error Handling Component
Build a reusable error boundary and error display system.
Requirements:
- Create ErrorDisplay component for API errors
- Handle different error types (network, timeout, validation)
- Show user-friendly messages
- Provide retry functionality
- Add error logging (console in dev, service in prod)
π‘ Hint
Use instanceof checks to determine error types. Create different UI for each error type. Accept an onRetry callback prop.
β Solution
See Section 5 for the complete ErrorDisplay component implementation!
ποΈ Challenge: Complete Task Management App
Build a full-featured task management application using everything you've learned.
Requirements:
- User authentication (login/logout)
- Task CRUD operations
- Filter tasks by status and priority
- Pagination for task list
- Proper error handling with retry
- Loading states for all operations
- TypeScript types for everything
- Environment variables for API URL
- Service layer architecture
π‘ Architecture Tips
Project Structure:
src/
βββ components/
β βββ TaskList.tsx
β βββ TaskForm.tsx
β βββ TaskFilter.tsx
β βββ ErrorDisplay.tsx
β βββ ProtectedRoute.tsx
βββ pages/
β βββ Login.tsx
β βββ Dashboard.tsx
β βββ TaskDetail.tsx
βββ services/
β βββ api/
β βββ apiClient.ts
β βββ authService.ts
β βββ taskService.ts
β βββ index.ts
βββ types/
β βββ api/
β β βββ task.ts
β β βββ user.ts
β β βββ index.ts
β βββ errors.ts
βββ config/
β βββ env.ts
βββ hooks/
β βββ useAuth.tsx
β βββ useTasks.tsx
βββ App.tsx
π Best Practices
β Do's
- Use a service layer - Centralize all API calls in service files, not scattered in components.
- Type everything - Create TypeScript interfaces for all requests, responses, and errors.
- Handle all error cases - Network errors, timeouts, 4xx, 5xx, validation errors all need handling.
- Use environment variables - Never hardcode API URLs or configuration.
- Implement authentication properly - Store tokens securely, handle expiration, protect routes.
- Show loading states - Users need feedback during async operations.
- Provide retry options - Let users retry failed requests.
- Use AbortController - Cancel requests when components unmount or dependencies change.
- Validate at runtime - Use libraries like Zod to validate API responses.
- Log errors properly - Use error tracking services (Sentry, LogRocket) in production.
β Don'ts
- Don't scatter API calls - Keep them in services, not directly in components.
- Don't hardcode URLs - Use environment variables for configuration.
- Don't ignore errors - Every API call can fail; handle it gracefully.
- Don't store secrets client-side - API keys, passwords, etc. should never be in client code.
- Don't retry everything - 4xx client errors won't succeed on retry.
- Don't forget cleanup - Cancel in-flight requests when components unmount.
- Don't trust API data - Validate responses at runtime.
- Don't expose implementation details - Services should return clean, typed data.
π‘ Pro Tips
- Use React Query or SWR - These libraries handle caching, refetching, and state management better than manual solutions.
- Implement request deduplication - If 5 components request the same data, make only one API call.
- Use interceptors - Add logging, auth tokens, and error handling globally.
- Implement optimistic updates - Update UI immediately, rollback on failure.
- Cache aggressively - Most data doesn't change that often; cache it!
- Prefetch data - Load data before users need it for instant perceived performance.
- Use HTTP/2 - Multiple concurrent requests are efficient with HTTP/2.
- Monitor API performance - Track response times, error rates, and success rates.
- Version your API - Use /v1/, /v2/ in URLs for backward compatibility.
- Document your services - Add JSDoc comments explaining what each method does.
β API Integration Checklist
- β Service layer created with typed methods
- β API client with error handling and interceptors
- β Authentication with JWT tokens
- β Protected routes implemented
- β Environment variables configured
- β Error display components created
- β Loading states shown to users
- β Request cancellation with AbortController
- β TypeScript types for all API interactions
- β Retry logic for failed requests
- β Validation with Zod or similar
- β Error tracking service integrated
Security Best Practices
- Always use HTTPS - Never send tokens or sensitive data over HTTP
- Store tokens securely - localStorage for SPAs, httpOnly cookies for SSR
- Implement CORS properly - Configure allowed origins on your server
- Validate on server - Client-side validation is for UX, not security
- Use Content Security Policy - Prevent XSS attacks
- Sanitize user input - Never trust user-provided data
- Implement rate limiting - Prevent abuse of your API
- Log security events - Track failed login attempts, unusual patterns
Performance Best Practices
- Minimize request size - Send only necessary data
- Use pagination - Don't fetch 1000 items at once
- Implement caching - Reduce redundant API calls
- Compress responses - Enable gzip/brotli on your server
- Use CDN for static assets - Serve images, fonts, etc. from CDN
- Batch requests - Combine multiple small requests into one
- Debounce search inputs - Wait for users to stop typing
- Use HTTP/2 Server Push - Push resources before they're requested
π Summary
π Key Takeaways
- REST APIs use HTTP methods - GET, POST, PUT, PATCH, DELETE for different operations
- CRUD is fundamental - Create, Read, Update, Delete are the building blocks of most apps
- Service layers organize API calls - Centralized, maintainable, testable architecture
- Authentication uses JWT tokens - Store tokens, send with requests, handle expiration
- Error handling is critical - Handle network errors, timeouts, 4xx, 5xx, validation errors
- TypeScript makes APIs safer - Type requests, responses, errors, and use discriminated unions
- Environment variables configure apps - Use .env files for API URLs and settings
- API clients centralize logic - Interceptors, error handling, and auth in one place
- Retry and resilience matter - Implement retry logic with exponential backoff
- Security is non-negotiable - HTTPS, token storage, CORS, validation, and more
π Additional Resources
- RESTful API Design Guidelines
- JWT.io - JSON Web Token Introduction
- MDN: Fetch API
- TanStack Query (React Query)
- Zod - Runtime Validation
- OAuth 2.0 - Advanced Authentication
- OWASP Web Security
π What's Next?
Congratulations! You've mastered API integration in React with TypeScript. You now know how to build production-ready applications that communicate with backend servers securely and efficiently.
Next, we'll work on the Module 4 Project: Weather Dashboard, where you'll apply everything:
- Build a complete weather app with city search
- Integrate with a real weather API
- Implement caching and error handling
- Add authentication and user preferences
- Deploy to production with environment variables
π― Quick Quiz
Question 1: Which HTTP method should you use to partially update a resource?
Question 2: Where should API calls be organized in a React application?
Question 3: How should JWT tokens be stored in a React SPA?
Question 4: What's the correct way to handle a 401 Unauthorized error?
Question 5: Why use environment variables for API URLs?
π Congratulations!
You've completed Lesson 4.5: Working with APIs! You now have the skills to build production-ready applications that communicate with backend servers securely and efficiently.
You've learned professional API integration patterns used by companies worldwide. Time to build something amazing! π