Features: - Zero configuration, just works out of the box - Runtime type inference and validation - Built-in caching with W-TinyLFU algorithm - Automatic retries with exponential backoff - Circuit breaker for resilience - Request deduplication - Offline support with queue - OpenAPI schema discovery - Full TypeScript support with type descriptors - Modular architecture - Configurable for advanced use cases Built with bun, ready for npm publishing
27 KiB
Chapter 4: POST, PUT, DELETE - The Full CRUD
"Reading data is nice, but real apps need to create, update, and delete things too."
From Consumer to Creator
Sarah's Weather Buddy app was a hit at the office. But her boss had a new request: "This is great for checking weather, but can we build something that lets people save their favorite cities and share their dashboard with others?"
"That means I need to store data, not just read it," Sarah realized.
Marcus overheard. "Time to learn about POST, PUT, and DELETE. The other three-quarters of CRUD."
"CRUD?" Sarah asked.
"Create, Read, Update, Delete. You've mastered Read with GET. Now let's complete your arsenal."
Understanding CRUD Operations
Remember our restaurant metaphor? If GET is like reading the menu, then:
- POST is like placing a new order
- PUT is like changing your order completely
- PATCH is like modifying part of your order
- DELETE is like canceling your order
Each has a specific purpose in the API world:
// GET - Read data (You know this!)
const users = await tf.get('/api/users')
// POST - Create new data
const newUser = await tf.post('/api/users', {
data: { name: 'Sarah Chen', role: 'developer' }
})
// PUT - Replace entire resource
const updated = await tf.put('/api/users/123', {
data: { name: 'Sarah Chen', role: 'senior developer' }
})
// PATCH - Update part of resource
const patched = await tf.patch('/api/users/123', {
data: { role: 'tech lead' }
})
// DELETE - Remove resource
await tf.delete('/api/users/123')
POST: Creating New Things
POST is how you add new data to an API. It's like filling out a form and hitting submit.
Basic POST Request
// Creating a new todo item
const { data: newTodo } = await tf.post('https://jsonplaceholder.typicode.com/todos', {
data: {
title: 'Learn TypedFetch POST requests',
completed: false,
userId: 1
}
})
console.log('Created todo:', newTodo)
// Output: { id: 201, title: 'Learn TypedFetch POST requests', completed: false, userId: 1 }
Notice what TypedFetch handles automatically:
- Sets
Content-Type: application/json
- Converts your data to JSON
- Parses the response
- Handles errors
Real Example: User Registration
Let's build a user registration system:
async function registerUser(email, password, name) {
try {
const { data } = await tf.post('https://api.myapp.com/auth/register', {
data: {
email,
password,
name,
acceptedTerms: true,
signupSource: 'web'
}
})
// Save the auth token
localStorage.setItem('authToken', data.token)
localStorage.setItem('userId', data.user.id)
return {
success: true,
user: data.user
}
} catch (error) {
// TypedFetch provides detailed error info
if (error.response?.status === 409) {
return {
success: false,
error: 'Email already registered'
}
}
return {
success: false,
error: error.message,
suggestions: error.suggestions
}
}
}
// Usage
const result = await registerUser('sarah@example.com', 'secure123', 'Sarah Chen')
if (result.success) {
console.log('Welcome,', result.user.name)
} else {
console.error('Registration failed:', result.error)
}
POST with Different Content Types
Not everything is JSON. Here's how to handle other formats:
// Form data (like traditional HTML forms)
const formData = new FormData()
formData.append('username', 'sarah_chen')
formData.append('avatar', fileInput.files[0])
const { data } = await tf.post('/api/upload', {
data: formData
// TypedFetch detects FormData and sets the right Content-Type
})
// URL-encoded data (for legacy APIs)
const { data: token } = await tf.post('/oauth/token', {
data: new URLSearchParams({
grant_type: 'password',
username: 'sarah@example.com',
password: 'secure123',
client_id: 'my-app'
})
})
// Plain text
const { data: result } = await tf.post('/api/parse', {
data: 'Plain text content here',
headers: {
'Content-Type': 'text/plain'
}
})
PUT: Complete Replacement
PUT replaces an entire resource. It's like saying "forget what you had, here's the new version."
// Get current user data
const { data: user } = await tf.get('/api/users/123')
// Update ALL fields (PUT requires complete data)
const { data: updated } = await tf.put('/api/users/123', {
data: {
id: 123,
name: 'Sarah Chen',
email: 'sarah.chen@example.com',
role: 'Senior Developer', // Changed this
department: 'Engineering',
startDate: '2022-01-15',
active: true
}
})
PUT vs PATCH: When to Use Which?
// ❌ Wrong: Using PUT with partial data
const { data } = await tf.put('/api/users/123', {
data: { role: 'Tech Lead' } // Missing other required fields!
})
// ✅ Right: Using PATCH for partial updates
const { data } = await tf.patch('/api/users/123', {
data: { role: 'Tech Lead' } // Only updates role
})
// ✅ Right: Using PUT with complete data
const { data: user } = await tf.get('/api/users/123')
const { data: updated } = await tf.put('/api/users/123', {
data: {
...user,
role: 'Tech Lead' // Change what you need
}
})
PATCH: Surgical Updates
PATCH is for partial updates. You only send what changed.
// Update just the fields that changed
const { data } = await tf.patch('/api/users/123', {
data: {
role: 'Tech Lead',
salary: 120000
}
})
// Using JSON Patch format (for APIs that support it)
const { data: patched } = await tf.patch('/api/users/123', {
data: [
{ op: 'replace', path: '/role', value: 'Tech Lead' },
{ op: 'add', path: '/skills/-', value: 'Leadership' },
{ op: 'remove', path: '/temporaryAccess' }
],
headers: {
'Content-Type': 'application/json-patch+json'
}
})
DELETE: Removing Resources
DELETE is straightforward - it removes things. But there are nuances:
// Simple delete
await tf.delete('/api/posts/456')
// Delete with confirmation
const { data } = await tf.delete('/api/users/123', {
data: {
confirmation: 'DELETE_USER_123',
reason: 'User requested account deletion'
}
})
// Soft delete (marking as deleted without removing)
const { data } = await tf.patch('/api/posts/789', {
data: {
deleted: true,
deletedAt: new Date().toISOString()
}
})
Handling DELETE Responses
Different APIs handle DELETE differently:
try {
const response = await tf.delete('/api/items/123')
// Some APIs return the deleted item
if (response.data) {
console.log('Deleted:', response.data)
}
// Some return 204 No Content
if (response.response.status === 204) {
console.log('Successfully deleted')
}
// Some return a confirmation
if (response.data?.message) {
console.log(response.data.message)
}
} catch (error) {
if (error.response?.status === 404) {
console.log('Item already deleted')
} else {
console.error('Delete failed:', error.message)
}
}
Building Weather Buddy 4.0: Full CRUD
Let's add user preferences to Weather Buddy:
<!DOCTYPE html>
<html>
<head>
<title>Weather Buddy 4.0 - Save Your Cities</title>
<style>
body { font-family: Arial, sans-serif; margin: 20px; }
.city-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 20px; }
.city-card { border: 1px solid #ddd; padding: 15px; border-radius: 8px; position: relative; }
.auth-section { background: #f0f0f0; padding: 20px; margin-bottom: 20px; border-radius: 8px; }
.delete-btn { position: absolute; top: 10px; right: 10px; background: #ff4444; color: white; border: none; padding: 5px 10px; cursor: pointer; }
.save-btn { background: #44ff44; color: black; border: none; padding: 10px 20px; cursor: pointer; margin: 10px 0; }
.loading { opacity: 0.6; }
.error { color: red; }
.success { color: green; }
</style>
<script type="module">
import { tf } from 'https://esm.sh/typedfetch'
// API configuration
const API_BASE = 'https://api.weatherbuddy.com'
let currentUser = null
// Create authenticated TypedFetch instance
const api = tf.create({
baseURL: API_BASE,
headers: () => ({
'Authorization': localStorage.getItem('authToken')
? `Bearer ${localStorage.getItem('authToken')}`
: undefined
})
})
// User authentication
window.login = async function() {
const email = document.getElementById('email').value
const password = document.getElementById('password').value
try {
const { data } = await api.post('/auth/login', {
data: { email, password }
})
localStorage.setItem('authToken', data.token)
localStorage.setItem('userId', data.user.id)
currentUser = data.user
showStatus('Logged in successfully!', 'success')
loadUserCities()
updateUI()
} catch (error) {
showStatus(error.message, 'error')
}
}
window.register = async function() {
const email = document.getElementById('email').value
const password = document.getElementById('password').value
const name = prompt('What\'s your name?')
try {
const { data } = await api.post('/auth/register', {
data: { email, password, name }
})
localStorage.setItem('authToken', data.token)
localStorage.setItem('userId', data.user.id)
currentUser = data.user
showStatus('Account created!', 'success')
updateUI()
} catch (error) {
if (error.response?.status === 409) {
showStatus('Email already registered', 'error')
} else {
showStatus(error.message, 'error')
}
}
}
window.logout = function() {
localStorage.clear()
currentUser = null
document.getElementById('cityGrid').innerHTML = ''
updateUI()
showStatus('Logged out', 'success')
}
// City management
window.addCity = async function(cityName) {
if (!currentUser) {
showStatus('Please login first', 'error')
return
}
try {
// First, get weather to verify city exists
const { data: weather } = await tf.get(`https://wttr.in/${cityName}?format=j1`)
// Save to user's cities
const { data: savedCity } = await api.post('/users/me/cities', {
data: {
name: cityName,
country: weather.nearest_area[0].country[0].value,
timezone: weather.timezone,
position: document.querySelectorAll('.city-card').length
}
})
addCityCard(savedCity, weather)
showStatus(`Added ${cityName}`, 'success')
} catch (error) {
showStatus(`Failed to add ${cityName}: ${error.message}`, 'error')
}
}
window.updateCityPosition = async function(cityId, newPosition) {
try {
await api.patch(`/users/me/cities/${cityId}`, {
data: { position: newPosition }
})
} catch (error) {
console.error('Failed to update position:', error)
}
}
window.deleteCity = async function(cityId, cityName) {
if (!confirm(`Remove ${cityName} from your dashboard?`)) return
try {
await api.delete(`/users/me/cities/${cityId}`)
document.getElementById(`city-${cityId}`).remove()
showStatus(`Removed ${cityName}`, 'success')
} catch (error) {
showStatus(`Failed to remove ${cityName}`, 'error')
}
}
window.shareDashboard = async function() {
try {
const { data } = await api.post('/share/dashboard', {
data: {
userId: currentUser.id,
cities: Array.from(document.querySelectorAll('.city-card'))
.map(card => card.dataset.cityName)
}
})
const shareUrl = `${window.location.origin}/shared/${data.shareId}`
if (navigator.clipboard) {
await navigator.clipboard.writeText(shareUrl)
showStatus('Share link copied to clipboard!', 'success')
} else {
prompt('Share this link:', shareUrl)
}
} catch (error) {
showStatus('Failed to create share link', 'error')
}
}
window.updatePreferences = async function() {
const units = document.getElementById('units').value
const refreshInterval = document.getElementById('refresh').value
try {
const { data } = await api.patch('/users/me/preferences', {
data: {
temperatureUnit: units,
refreshInterval: parseInt(refreshInterval)
}
})
currentUser.preferences = data
showStatus('Preferences updated', 'success')
// Refresh all city cards with new units
document.querySelectorAll('.city-card').forEach(card => {
updateWeatherDisplay(card.id.replace('city-', ''))
})
} catch (error) {
showStatus('Failed to update preferences', 'error')
}
}
// Load user's saved cities
async function loadUserCities() {
try {
const { data: cities } = await api.get('/users/me/cities')
document.getElementById('cityGrid').innerHTML = ''
// Load cities in parallel
const weatherPromises = cities
.sort((a, b) => a.position - b.position)
.map(city =>
tf.get(`https://wttr.in/${city.name}?format=j1`)
.then(({ data }) => ({ city, weather: data }))
.catch(() => ({ city, weather: null }))
)
const results = await Promise.all(weatherPromises)
results.forEach(({ city, weather }) => {
if (weather) {
addCityCard(city, weather)
}
})
} catch (error) {
console.error('Failed to load cities:', error)
}
}
// Add city card to grid
function addCityCard(city, weather) {
const card = document.createElement('div')
card.className = 'city-card'
card.id = `city-${city.id}`
card.dataset.cityName = city.name
const units = currentUser?.preferences?.temperatureUnit || 'C'
const temp = units === 'C'
? weather.current_condition[0].temp_C
: weather.current_condition[0].temp_F
card.innerHTML = `
<button class="delete-btn" onclick="deleteCity('${city.id}', '${city.name}')">×</button>
<h3>${city.name}</h3>
<p>🌡️ ${temp}°${units}</p>
<p>🌤️ ${weather.current_condition[0].weatherDesc[0].value}</p>
<p>💨 ${weather.current_condition[0].windspeedKmph} km/h</p>
<p>💧 ${weather.current_condition[0].humidity}%</p>
`
document.getElementById('cityGrid').appendChild(card)
}
// Utility functions
function showStatus(message, type) {
const status = document.getElementById('status')
status.textContent = message
status.className = type
setTimeout(() => status.textContent = '', 3000)
}
function updateUI() {
const authSection = document.getElementById('authSection')
const mainSection = document.getElementById('mainSection')
if (currentUser) {
authSection.style.display = 'none'
mainSection.style.display = 'block'
document.getElementById('userName').textContent = currentUser.name
} else {
authSection.style.display = 'block'
mainSection.style.display = 'none'
}
}
// Check if already logged in
window.addEventListener('load', async () => {
if (localStorage.getItem('authToken')) {
try {
const { data } = await api.get('/users/me')
currentUser = data
updateUI()
loadUserCities()
} catch (error) {
// Token expired
localStorage.clear()
updateUI()
}
}
})
</script>
</head>
<body>
<h1>Weather Buddy 4.0 - Your Personal Weather Dashboard 🌍</h1>
<div id="status"></div>
<div id="authSection" class="auth-section">
<h2>Login or Register</h2>
<input type="email" id="email" placeholder="Email" />
<input type="password" id="password" placeholder="Password" />
<button onclick="login()">Login</button>
<button onclick="register()">Register</button>
</div>
<div id="mainSection" style="display: none;">
<div class="auth-section">
<p>Welcome, <span id="userName"></span>!</p>
<button onclick="logout()">Logout</button>
<button onclick="shareDatabase()">Share Dashboard</button>
<div style="margin-top: 10px;">
<label>Temperature Unit:
<select id="units" onchange="updatePreferences()">
<option value="C">Celsius</option>
<option value="F">Fahrenheit</option>
</select>
</label>
<label>Refresh Every:
<select id="refresh" onchange="updatePreferences()">
<option value="60">1 minute</option>
<option value="300">5 minutes</option>
<option value="600">10 minutes</option>
</select>
</label>
</div>
</div>
<div class="search-box">
<input
type="text"
id="citySearch"
placeholder="Add a city..."
onkeypress="if(event.key==='Enter') addCity(this.value)"
/>
<button onclick="addCity(document.getElementById('citySearch').value)">Add City</button>
</div>
<div id="cityGrid" class="city-grid"></div>
</div>
</body>
</html>
Advanced CRUD Patterns
1. Optimistic Updates
Update the UI immediately, then sync with server:
async function toggleTodoOptimistic(todoId, currentState) {
// Update UI immediately
const todoElement = document.getElementById(`todo-${todoId}`)
todoElement.classList.toggle('completed')
try {
// Sync with server
await tf.patch(`/api/todos/${todoId}`, {
data: { completed: !currentState }
})
} catch (error) {
// Revert on failure
todoElement.classList.toggle('completed')
showError('Failed to update todo')
}
}
2. Bulk Operations
Handle multiple items efficiently:
// Delete multiple items
async function deleteSelectedTodos(todoIds) {
try {
// Some APIs support bulk delete
await tf.post('/api/todos/bulk-delete', {
data: { ids: todoIds }
})
} catch (error) {
// Fallback to individual deletes
const results = await Promise.allSettled(
todoIds.map(id => tf.delete(`/api/todos/${id}`))
)
const failed = results.filter(r => r.status === 'rejected')
if (failed.length > 0) {
showError(`Failed to delete ${failed.length} items`)
}
}
}
// Bulk create
async function importTodos(todos) {
const { data } = await tf.post('/api/todos/bulk', {
data: { todos }
})
return data.created
}
3. Idempotent Requests
Make requests safe to retry:
// Using idempotency keys
async function createPayment(amount, currency) {
const idempotencyKey = crypto.randomUUID()
try {
const { data } = await tf.post('/api/payments', {
data: { amount, currency },
headers: {
'Idempotency-Key': idempotencyKey
}
})
return data
} catch (error) {
// Safe to retry with same idempotency key
if (error.code === 'NETWORK_ERROR') {
return tf.post('/api/payments', {
data: { amount, currency },
headers: {
'Idempotency-Key': idempotencyKey
}
})
}
throw error
}
}
4. Conditional Updates
Only update if resource hasn't changed:
// Get resource with ETag
const { data: user, response } = await tf.get('/api/users/123')
const etag = response.headers.get('etag')
// Update only if unchanged
try {
const { data: updated } = await tf.put('/api/users/123', {
data: {
...user,
role: 'Tech Lead'
},
headers: {
'If-Match': etag
}
})
} catch (error) {
if (error.response?.status === 412) {
console.error('User was modified by someone else!')
// Reload and try again
}
}
Error Handling in CRUD Operations
Each CRUD operation can fail differently:
async function handleCrudErrors() {
try {
await tf.post('/api/resources', { data: {} })
} catch (error) {
switch (error.response?.status) {
case 400:
console.error('Bad Request:', error.data?.errors)
break
case 401:
console.error('Not authenticated')
// Redirect to login
break
case 403:
console.error('Not authorized')
break
case 409:
console.error('Conflict - resource already exists')
break
case 422:
console.error('Validation failed:', error.data?.errors)
break
case 429:
console.error('Too many requests')
// Implement backoff
break
default:
console.error('Unexpected error:', error.message)
}
}
}
CRUD Best Practices
1. Use the Right Method
// ✅ Correct
await tf.post('/api/users', { data: newUser }) // Create
await tf.patch('/api/users/123', { data: changes }) // Partial update
await tf.put('/api/users/123', { data: fullUser }) // Full replace
await tf.delete('/api/users/123') // Delete
// ❌ Wrong
await tf.post('/api/users/123', { data: updates }) // POST shouldn't update
await tf.get('/api/users/delete/123') // GET shouldn't change data
2. Handle Loading States
function CrudButton({ action, endpoint, data }) {
const [loading, setLoading] = useState(false)
async function handleClick() {
setLoading(true)
try {
await tf[action](endpoint, { data })
showSuccess(`${action} successful`)
} catch (error) {
showError(error.message)
} finally {
setLoading(false)
}
}
return (
<button onClick={handleClick} disabled={loading}>
{loading ? 'Loading...' : action.toUpperCase()}
</button>
)
}
3. Validate Before Sending
async function createUser(userData) {
// Client-side validation
const errors = validateUserData(userData)
if (errors.length > 0) {
return { success: false, errors }
}
try {
const { data } = await tf.post('/api/users', { data: userData })
return { success: true, user: data }
} catch (error) {
// Server-side validation errors
if (error.response?.status === 422) {
return {
success: false,
errors: error.data.errors
}
}
throw error
}
}
Practice Time! 🏋️
Exercise 1: Todo App CRUD
Build a complete todo app with all CRUD operations:
// Your code here:
// 1. Create todo (POST)
// 2. List todos (GET)
// 3. Update todo (PATCH)
// 4. Delete todo (DELETE)
// 5. Bulk operations
Exercise 2: Resource Versioning
Implement optimistic locking with version numbers:
// Your code here:
// Track resource versions and handle conflicts
Exercise 3: Retry Logic
Build smart retry for failed mutations:
// Your code here:
// Retry with exponential backoff for safe operations
Key Takeaways 🎯
- POST creates, PUT replaces, PATCH updates, DELETE removes
- TypedFetch handles JSON automatically for all methods
- Use PATCH for partial updates instead of PUT
- Handle errors specifically - each status code means something
- Optimistic updates improve perceived performance
- Idempotency keys make retries safe
- Validate client-side first but always handle server validation
- Loading states are crucial for user experience
Common Pitfalls 🚨
- Using GET for state changes - Never modify data with GET
- Forgetting error handling - Mutations fail more than reads
- Not showing loading states - Users need feedback
- Ignoring HTTP status codes - They convey important info
- PUT with partial data - Use PATCH instead
- Not handling conflicts - Multiple users = conflicts
What's Next?
You've mastered CRUD operations! But what happens when things go wrong? In Chapter 5, we'll dive deep into error handling:
- Understanding every HTTP status code
- Building resilient retry strategies
- Creating helpful error messages
- Implementing circuit breakers
- Handling network failures gracefully
We'll make Weather Buddy bulletproof - able to handle any failure and recover gracefully.
Ready to become an error-handling ninja? See you in Chapter 5! 🥷
Chapter Summary
- CRUD = Create (POST), Read (GET), Update (PUT/PATCH), Delete (DELETE)
- POST creates new resources and returns the created item
- PUT replaces entire resources, PATCH updates parts
- DELETE removes resources, may return the deleted item or 204
- TypedFetch handles JSON serialization/parsing automatically
- Always handle specific error cases for better UX
- Optimistic updates make apps feel faster
- Use proper HTTP methods - don't use GET for mutations
- Weather Buddy now saves user preferences and syncs across devices
Next Chapter Preview: Error Handling Like a Pro - turning failures into features with smart retry logic, circuit breakers, and user-friendly error messages.