🔐 The Authentication Nightmare
Every API call needs an auth token. Every developer on your team copies the same 10 lines of code. Token refresh logic is duplicated 47 times across your codebase. Sound familiar?
The Solution: Fetch Interceptor Pattern
❌ The Repetitive Way (47 Places)
// Component A
const token = localStorage.getItem('token');
const response = await fetch('/api/users', {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
// Component B (same code copy-pasted)
const token = localStorage.getItem('token');
const response = await fetch('/api/posts', {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
// 45 more components with the same code...
// Token refresh? Good luck updating 47 places!
✅ Centralized API Client (1 Place)
// api.js - Your centralized API client
class ApiClient {
constructor() {
this.baseURL = process.env.REACT_APP_API_URL;
this.refreshing = false;
}
async request(endpoint, options = {}) {
// Interceptor: Add auth automatically
const token = localStorage.getItem('token');
const config = {
...options,
headers: {
'Content-Type': 'application/json',
'Authorization': token ? `Bearer ${token}` : '',
...options.headers
}
};
try {
const response = await fetch(`${this.baseURL}${endpoint}`, config);
// Interceptor: Handle 401 (token expired)
if (response.status === 401 && !this.refreshing) {
const newToken = await this.refreshToken();
if (newToken) {
// Retry original request with new token
return this.request(endpoint, options);
}
}
return response.json();
} catch (error) {
console.error('API Error:', error);
throw error;
}
}
async refreshToken() {
this.refreshing = true;
try {
const refresh = localStorage.getItem('refreshToken');
const response = await fetch(`${this.baseURL}/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh })
});
const { token } = await response.json();
localStorage.setItem('token', token);
return token;
} catch (error) {
// Refresh failed - log user out
localStorage.clear();
window.location = '/login';
return null;
} finally {
this.refreshing = false;
}
}
// Convenience methods
get(endpoint) { return this.request(endpoint); }
post(endpoint, data) {
return this.request(endpoint, {
method: 'POST',
body: JSON.stringify(data)
});
}
put(endpoint, data) {
return this.request(endpoint, {
method: 'PUT',
body: JSON.stringify(data)
});
}
delete(endpoint) {
return this.request(endpoint, { method: 'DELETE' });
}
}
export const api = new ApiClient();
🎯 Usage: Clean & Simple
// Component A
import { api } from './api';
const users = await api.get('/users');
// ✅ Auth token added automatically
// ✅ Token refresh handled automatically
// ✅ Errors logged automatically
// Component B
const post = await api.post('/posts', { title: 'Hello' });
// Same benefits, zero boilerplate!
// Component C
const updated = await api.put('/posts/123', { title: 'Updated' });
// Component D
await api.delete('/posts/123');
// All 47 components: Clean, simple, DRY
| Approach | Code Duplication | Token Refresh | Maintainability |
|---|---|---|---|
| Direct fetch() calls | 47 places | Manual (breaks) | Nightmare |
| Axios (old way) | Some shared | Interceptors | OK (+35KB) |
| API Client Pattern | Zero! | Automatic | Perfect (0KB) |
🚀 Advanced Features to Add
class ApiClient {
// ... previous code ...
// Request deduplication
pendingRequests = new Map();
async request(endpoint, options = {}) {
const key = `${options.method || 'GET'}-${endpoint}`;
// If same request already in flight, return existing promise
if (this.pendingRequests.has(key)) {
return this.pendingRequests.get(key);
}
const promise = this._makeRequest(endpoint, options);
this.pendingRequests.set(key, promise);
promise.finally(() => this.pendingRequests.delete(key));
return promise;
}
// Request caching
cache = new Map();
async getCached(endpoint, ttl = 60000) {
const cached = this.cache.get(endpoint);
if (cached && Date.now() - cached.timestamp < ttl) {
return cached.data;
}
const data = await this.get(endpoint);
this.cache.set(endpoint, { data, timestamp: Date.now() });
return data;
}
// Request retry with exponential backoff
async requestWithRetry(endpoint, options = {}, maxRetries = 3) {
for (let i = 0; i <= maxRetries; i++) { try { return await this.request(endpoint, options); }
catch (error) { if (i === maxRetries) throw error; const delay = Math.pow(2, i) * 1000;
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
}
💡 Real-World Benefits
- Token refresh: Changed once, works everywhere
- Error handling: Centralized logging to Sentry
- Loading states: Global loading indicator
- Rate limiting: Add throttling in one place
- API versioning: Switch v1 to v2 globally
- Mock API: Swap real API with mock for testing
“We had auth logic scattered across 63 components. Migrating to API Client took 2 hours. Saved us 100+ hours in future maintenance. Best refactor ever.”
⚠️ When NOT to Use This Pattern
- Tiny apps with 1-2 API calls (overkill)
- Already using tRPC, GraphQL, or React Query (they handle this)
- Team prefers Axios (interceptors work similarly)
But for most SPAs with RESTful APIs? This pattern is gold.
