TypeFetched/manual/chapter-4-crud-operations.md
Casey Collier b85b9a63e2 Initial commit: TypedFetch - Zero-dependency, type-safe HTTP client
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
2025-07-20 12:35:43 -04:00

27 KiB
Raw Blame History

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 🎯

  1. POST creates, PUT replaces, PATCH updates, DELETE removes
  2. TypedFetch handles JSON automatically for all methods
  3. Use PATCH for partial updates instead of PUT
  4. Handle errors specifically - each status code means something
  5. Optimistic updates improve perceived performance
  6. Idempotency keys make retries safe
  7. Validate client-side first but always handle server validation
  8. Loading states are crucial for user experience

Common Pitfalls 🚨

  1. Using GET for state changes - Never modify data with GET
  2. Forgetting error handling - Mutations fail more than reads
  3. Not showing loading states - Users need feedback
  4. Ignoring HTTP status codes - They convey important info
  5. PUT with partial data - Use PATCH instead
  6. 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.