useMutation: Handling POST, PUT, DELETE Operations

While useQuery (as I wrote in a previous post) handles GET requests beautifully, useMutation is designed for operations that change data on the server—creating, updating, or deleting resources.

Without useMutation: The Manual Way

import { useState } from 'react';

interface Product {
  id: number;
  title: string;
  price: number;
  description: string;
}

export default function CreateProduct() {
  const [title, setTitle] = useState('');
  const [price, setPrice] = useState('');
  const [isSubmitting, setIsSubmitting] = useState(false);
  const [error, setError] = useState<Error | null>(null);
  const [success, setSuccess] = useState(false);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    
    try {
      setIsSubmitting(true);
      setError(null);
      setSuccess(false);
      
      const response = await fetch('/api/products', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          title,
          price: parseFloat(price),
          description: 'Product description',
        }),
      });
      
      if (!response.ok) {
        throw new Error('Failed to create product');
      }
      
      const data = await response.json();
      setSuccess(true);
      
      // Now you need to manually invalidate/refetch the products list
      // Or pass a callback to parent component to refresh
      
      // Reset form
      setTitle('');
      setPrice('');
    } catch (err) {
      setError(err instanceof Error ? err : new Error('Unknown error'));
    } finally {
      setIsSubmitting(false);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <h2>Create Product</h2>
      
      {error && <div className="error">{error.message}</div>}
      {success && <div className="success">Product created!</div>}
      
      <div>
        <label>Title:</label>
        <input
          type="text"
          value={title}
          onChange={(e) => setTitle(e.target.value)}
          disabled={isSubmitting}
        />
      </div>
      
      <div>
        <label>Price:</label>
        <input
          type="number"
          value={price}
          onChange={(e) => setPrice(e.target.value)}
          disabled={isSubmitting}
        />
      </div>
      
      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Creating...' : 'Create Product'}
      </button>
    </form>
  );
}

Problems with this approach:

  • Manual state management for submitting, error, and success states
  • No automatic cache invalidation—the products list won’t update
  • Need to manually coordinate between create and list components
  • No retry logic for failed requests No optimistic updates
  • Repetitive boilerplate for every mutation

With useMutation: The Clean Way

Here’s a typical implementation:

import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';

interface Product {
  id: number;
  title: string;
  price: number;
  description: string;
}

interface CreateProductData {
  title: string;
  price: number;
  description: string;
}

const createProduct = async (newProduct: CreateProductData): Promise<Product> => {
  const response = await fetch('/api/products', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(newProduct),
  });
  
  if (!response.ok) {
    throw new Error('Failed to create product');
  }
  
  return response.json();
};

export default function CreateProduct() {
  const [title, setTitle] = useState('');
  const [price, setPrice] = useState('');
  const queryClient = useQueryClient();

  const mutation = useMutation({
    mutationFn: createProduct,
    onSuccess: () => {
      // Automatically invalidate and refetch products list
      queryClient.invalidateQueries({ queryKey: ['products'] });
      
      // Reset form
      setTitle('');
      setPrice('');
    },
  });

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    
    mutation.mutate({
      title,
      price: parseFloat(price),
      description: 'Product description',
    });
  };

  return (
    <form onSubmit={handleSubmit}>
      <h2>Create Product</h2>
      
      {mutation.isError && (
        <div className="error">{mutation.error.message}</div>
      )}
      {mutation.isSuccess && (
        <div className="success">Product created!</div>
      )}
      
      <div>
        <label>Title:</label>
        <input
          type="text"
          value={title}
          onChange={(e) => setTitle(e.target.value)}
          disabled={mutation.isPending}
        />
      </div>
      
      <div>
        <label>Price:</label>
        <input
          type="number"
          value={price}
          onChange={(e) => setPrice(e.target.value)}
          disabled={mutation.isPending}
        />
      </div>
      
      <button type="submit" disabled={mutation.isPending}>
        {mutation.isPending ? 'Creating...' : 'Create Product'}
      </button>
    </form>
  );
}

Problems with this approach:

  • Repetitive boilerplate – Every component needs the same state management
  • No caching – Navigate away and back? Fetch again
  • Race conditions – If the component unmounts during fetch, you’ll get state updates on unmounted components
  • No background updates – Data can become stale with no automatic refresh
  • Manual error handling – You’re responsible for every edge case
  • No request deduplication – Multiple components fetching the same data make multiple requests

The New Way: TanStack Query

import { useQuery } from '@tanstack/react-query';

interface Product {
  id: number;
  title: string;
  price: number;
  description: string;
}

const fetchProducts = async (): Promise<Product[]> => {
  const response = await fetch('/api/products');
  
  if (!response.ok) {
    throw new Error('Failed to fetch products');
  }
  
  return response.json();
};

export default function ProductsList() {
  const { data: products, isLoading, error } = useQuery({
    queryKey: ['products'],
    queryFn: fetchProducts,
  });

  if (isLoading) return <div>Loading products...</div>;
  if (error) return <div>Error: {error.message}</div>;

  return (
    <div>
      <h1>Products</h1>
      <ul>
        {products?.map((product) => (
          <li key={product.id}>
            <h3>{product.title}</h3>
            <p>${product.price}</p>
            <p>{product.description}</p>
          </li>
        ))}
      </ul>
    </div>
  );
}

Key Benefits of useMutation

Automatic Cache Invalidation

When you create/update/delete data, useMutation can automatically invalidate related queries. The products list refetches automatically without any manual coordination.

onSuccess: () => {
  queryClient.invalidateQueries({ queryKey: ['products'] });
}

Built-in State Management

No need for useState to track loading, error, and success states. Everything is handled:

mutation.isPending  // Loading state
mutation.isError    // Error occurred
mutation.isSuccess  // Successfully completed
mutation.error      // Error object
mutation.data       // Response data

Automatic Retries

Failed mutations can retry automatically with configurable logic:

const mutation = useMutation({
  mutationFn: createProduct,
  retry: 3, // Retry 3 times before giving up
});

That’s it.

What Just Happened?

TanStack Query handles everything for you:

1. Automatic State Management No more useState for data, loading, and error states. The useQuery hook returns everything you need with proper TypeScript types inferred automatically.

2. Intelligent Caching The queryKey acts as a unique identifier. Navigate to another page and back? The cached data displays instantly while TanStack Query refetches in the background to ensure freshness.

3. Background Refetching When you focus the browser window or reconnect to the internet, TanStack Query automatically refetches stale data. Your users always see up-to-date information without manual intervention.

4. Request Deduplication If three components mount at the same time and all request ['products'], only one network request fires. All three components share the result.

5. Built-in Error Retry Failed requests automatically retry with exponential backoff. No more writing retry logic yourself.

6. Developer Experience Install the React Query DevTools and see every query, its status, cache time, and data in a beautiful debugging interface.

References

TanStack: https://tanstack.com/query/v5/docs/framework/react/reference/useQuery

Scroll to Top