ASP.NET Core  

GraphQL with .NET & React | Part 3: Mutations (Create, Update & Delete)

Series: GraphQL with .NET & React, From Zero to Production

Welcome to this comprehensive GraphQL tutorial series! By the end of these 5 articles, you'll have a solid understanding of GraphQL and hands-on experience building a full-stack application with React and .NET.

Get your code here: https://github.com/RikamPalkar/graphql-dotnet-react-from-zero-to-production

This is the series that takes you from GraphQL basics to advanced patterns.

Part 1: Foundations & Library Backend

Part 2: Query (Arguments, Aliases & Fragments)

Part 3: Mutations (Create, Update & Delete)

Welcome to Part 3! We've mastered reading data with queries. Now it's time to learn how to modify data with mutations - the GraphQL way of creating, updating, and deleting records.

Where We Left Off

We can now write sophisticated queries with arguments, variables, fragments, and filtering. Our Library app displays books and authors beautifully. But what about adding new books or writing reviews? That's where mutations come in!

What Are Mutations?

Mutations are GraphQL operations that change data on the server. If queries are like SELECT in SQL, mutations are like INSERT, UPDATE, and DELETE.

Why "Mutation"?

The name comes from the concept of "mutating" (changing) data. While queries just read data, mutations cause side effects - they change something on the server.

Queries vs Mutations

AspectQueryMutation
PurposeRead dataModify data
Side effectsNoneExpected
ExecutionCan run in parallelRun one at a time, in order
Keywordquery (or omit)mutation (required)
AnalogySELECT / GETINSERT/UPDATE/DELETE / POST/PUT/DELETE

Why Sequential Execution Matters

Imagine these two mutations:

  1. Create a user

  2. Create a post for that user

If they ran in parallel, #2 might run before #1 completes, failing because the user doesn't exist yet!

GraphQL guarantees mutations run in the order you write them.

Mutation Syntax

Basic Structure

mutation OperationName {
  mutationField(arguments) {
    # Fields to return after the mutation
  }
}

Example:

mutation CreateBook {
  addBook(input: { title: "New Book", authorId: 1, ... }) {
    book {
      id
      title
    }
  }
}

With Variables (Recommended)

mutation CreateBook($input: AddBookInput!) {
  addBook(input: $input) {
    book {
      id
      title
    }
    error
  }
}

Variables JSON:

{
  "input": {
    "title": "New Book",
    "authorId": 1,
    "publishedYear": 2024,
    "price": 19.99,
    "pageCount": 200
  }
}

Input Types

What Are Input Types?

Input types are special GraphQL types used for mutation arguments. They group related parameters together.

Without input type:

mutation {
  addBook(
    title: "New Book",
    description: "A great book",
    isbn: "123-456",
    publishedYear: 2024,
    genre: "Fiction",
    price: 19.99,
    pageCount: 300,
    authorId: 1
  ) { ... }
}

With input type (cleaner!):

mutation {
  addBook(input: {
    title: "New Book",
    description: "A great book",
    isbn: "123-456",
    publishedYear: 2024,
    genre: "Fiction",
    price: 19.99,
    pageCount: 300,
    authorId: 1
  }) { ... }
}

Defining Input Types (Backend)

In C#, input types are typically records:

// GraphQL/Types/InputTypes.cs

// 'record' is a C# feature for immutable data classes
// It automatically generates constructor, equality, etc.

public record AddBookInput(
    string Title,           // Required - no '?'
    string? Description,    // Optional - has '?'
    string? Isbn,
    int PublishedYear,
    string? Genre,
    decimal Price,
    int PageCount,
    int AuthorId,
    List<int>? CategoryIds  // Optional list
);

public record UpdateBookInput(
    int Id,                 // Required - which book to update
    string? Title,          // All optional - only update provided fields
    string? Description,
    string? Isbn,
    int? PublishedYear,
    string? Genre,
    decimal? Price,
    int? PageCount,
    bool? IsAvailable
);

Input Types in Schema

HotChocolate generates this schema:

input AddBookInput {
  title: String!
  description: String
  isbn: String
  publishedYear: Int!
  genre: String
  price: Decimal!
  pageCount: Int!
  authorId: Int!
  categoryIds: [Int!]
}

Notice:

  • input keyword (not type)

  • Can't have resolvers (unlike regular types)

  • Can only contain scalars or other input types

Creating Data (Add Mutations)

Backend Implementation

// GraphQL/Mutations/Mutation.cs
public class Mutation
{
    public async Task<AddBookPayload> AddBook(
        LibraryDbContext context,          // Injected by HotChocolate
        [Service] ITopicEventSender eventSender,  // For subscriptions (Part 4)
        AddBookInput input)                // The input from GraphQL
    {
        // 1. Validate: Check if author exists
        var authorExists = await context.Authors
            .AnyAsync(a => a.Id == input.AuthorId);
        
        if (!authorExists)
        {
            // Return error in payload instead of throwing
            return new AddBookPayload(null, "Author not found");
        }
        
        // 2. Create the entity
        var book = new Book
        {
            Title = input.Title,
            Description = input.Description,
            Isbn = input.Isbn,
            PublishedYear = input.PublishedYear,
            Genre = input.Genre,
            Price = input.Price,
            PageCount = input.PageCount,
            AuthorId = input.AuthorId
        };
        
        // 3. Save to database
        context.Books.Add(book);
        await context.SaveChangesAsync();
        
        // 4. Handle categories if provided
        if (input.CategoryIds?.Any() == true)
        {
            var bookCategories = input.CategoryIds
                .Select(categoryId => new BookCategory 
                { 
                    BookId = book.Id, 
                    CategoryId = categoryId 
                });
            context.BookCategories.AddRange(bookCategories);
            await context.SaveChangesAsync();
        }
        
        // 5. Publish event for subscriptions (covered in Part 4)
        await eventSender.SendAsync("OnBookAdded", book);
        
        // 6. Return success payload
        return new AddBookPayload(book, null);
    }
}

// Payload type - contains result OR error
public record AddBookPayload(Book? Book, string? Error);

GraphQL Mutation

mutation AddBook($input: AddBookInput!) {
  addBook(input: $input) {
    book {
      id
      title
      description
      publishedYear
      author {
        name
      }
    }
    error
  }
}

React Implementation

import { useMutation, gql } from '@apollo/client';

const ADD_BOOK = gql`
  mutation AddBook($input: AddBookInput!) {
    addBook(input: $input) {
      book {
        id
        title
        author { name }
      }
      error
    }
  }
`;

function AddBookForm() {
  const [form, setForm] = useState({
    title: '',
    description: '',
    publishedYear: 2024,
    genre: '',
    price: 0,
    pageCount: 0,
    authorId: 0,
  });

  // useMutation returns [mutateFunction, { loading, error, data }]
  const [addBook, { loading, error }] = useMutation(ADD_BOOK, {
    // Refetch books list after adding
    refetchQueries: ['GetBooks'],
    
    // Called when mutation completes
    onCompleted: (data) => {
      if (data.addBook.error) {
        alert(`Error: ${data.addBook.error}`);
      } else {
        alert(`Book "${data.addBook.book.title}" created!`);
        // Reset form...
      }
    }
  });

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    
    await addBook({
      variables: { input: form }
    });
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        value={form.title}
        onChange={(e) => setForm({...form, title: e.target.value})}
        placeholder="Title"
        required
      />
      {/* More form fields... */}
      
      <button type="submit" disabled={loading}>
        {loading ? 'Adding...' : 'Add Book'}
      </button>
      
      {error && <p className="error">Network error: {error.message}</p>}
    </form>
  );
}

Key useMutation concepts:

  • Returns a tuple: [mutationFunction, result]

  • mutationFunction - Call this to execute the mutation

  • loading - True while mutation is in progress

  • error - Network/GraphQL errors

  • refetchQueries - Re-run these queries after mutation succeeds

Updating Data (Update Mutations)

Partial Updates

Update mutations should only change provided fields:

public async Task<UpdateBookPayload> UpdateBook(
    LibraryDbContext context,
    UpdateBookInput input)
{
    // 1. Find the existing entity
    var book = await context.Books.FindAsync(input.Id);
    
    if (book == null)
        return new UpdateBookPayload(null, "Book not found");
    
    // 2. Only update fields that were provided
    // The '!= null' checks are important for partial updates
    if (input.Title != null) book.Title = input.Title;
    if (input.Description != null) book.Description = input.Description;
    if (input.Isbn != null) book.Isbn = input.Isbn;
    if (input.PublishedYear.HasValue) book.PublishedYear = input.PublishedYear.Value;
    if (input.Genre != null) book.Genre = input.Genre;
    if (input.Price.HasValue) book.Price = input.Price.Value;
    if (input.PageCount.HasValue) book.PageCount = input.PageCount.Value;
    if (input.IsAvailable.HasValue) book.IsAvailable = input.IsAvailable.Value;
    
    // 3. Save changes
    await context.SaveChangesAsync();
    
    return new UpdateBookPayload(book, null);
}

GraphQL Mutation

mutation UpdateBook($input: UpdateBookInput!) {
  updateBook(input: $input) {
    book {
      id
      title
      price
      isAvailable
    }
    error
  }
}

Partial update example:

{
  "input": {
    "id": 1,
    "price": 24.99
  }
}

Only the price changes - other fields remain untouched.

Deleting Data (Delete Mutations)

Backend Implementation

public async Task<DeletePayload> DeleteBook(
    LibraryDbContext context,
    int id)
{
    var book = await context.Books.FindAsync(id);
    
    if (book == null)
        return new DeletePayload(false, "Book not found");
    
    // Delete related records first (referential integrity)
    var reviews = context.Reviews.Where(r => r.BookId == id);
    context.Reviews.RemoveRange(reviews);
    
    var bookCategories = context.BookCategories.Where(bc => bc.BookId == id);
    context.BookCategories.RemoveRange(bookCategories);
    
    // Delete the book itself
    context.Books.Remove(book);
    await context.SaveChangesAsync();
    
    return new DeletePayload(true, null);
}

public record DeletePayload(bool Success, string? Error);

GraphQL Mutation

mutation DeleteBook($id: Int!) {
  deleteBook(id: $id) {
    success
    error
  }
}

React with Cache Update

When deleting, we need to remove the item from Apollo's cache:

const DELETE_BOOK = gql`
  mutation DeleteBook($id: Int!) {
    deleteBook(id: $id) {
      success
      error
    }
  }
`;

function DeleteBookButton({ bookId }: { bookId: number }) {
  const [deleteBook, { loading }] = useMutation(DELETE_BOOK, {
    variables: { id: bookId },
    
    // Manually update the cache after deletion
    update(cache, { data }) {
      if (data?.deleteBook.success) {
        // Evict removes the item from cache
        cache.evict({ id: `Book:${bookId}` });
        // Garbage collect removes orphaned references
        cache.gc();
      }
    },
    
    onCompleted(data) {
      if (data.deleteBook.success) {
        alert('Book deleted!');
      } else {
        alert(`Error: ${data.deleteBook.error}`);
      }
    }
  });

  const handleDelete = () => {
    if (confirm('Are you sure you want to delete this book?')) {
      deleteBook();
    }
  };

  return (
    <button onClick={handleDelete} disabled={loading}>
      {loading ? 'Deleting...' : 'Delete'}
    </button>
  );
}

Practical Example: Adding Reviews

Let's implement a complete review feature.

Backend

public record AddReviewInput(
    int BookId,
    string Title,
    string Content,
    int Rating,        // 1-5
    string ReviewerName
);

public async Task<AddReviewPayload> AddReview(
    LibraryDbContext context,
    [Service] ITopicEventSender eventSender,
    AddReviewInput input)
{
    // Validate rating range
    if (input.Rating < 1 || input.Rating > 5)
        return new AddReviewPayload(null, "Rating must be between 1 and 5");
    
    // Validate book exists
    var bookExists = await context.Books.AnyAsync(b => b.Id == input.BookId);
    if (!bookExists)
        return new AddReviewPayload(null, "Book not found");
    
    var review = new Review
    {
        BookId = input.BookId,
        Title = input.Title,
        Content = input.Content,
        Rating = input.Rating,
        ReviewerName = input.ReviewerName
    };
    
    context.Reviews.Add(review);
    await context.SaveChangesAsync();
    
    // Notify subscribers watching this book's reviews
    await eventSender.SendAsync($"OnReviewAdded_{input.BookId}", review);
    
    return new AddReviewPayload(review, null);
}

public record AddReviewPayload(Review? Review, string? Error);

React Component

function ReviewForm({ bookId }: { bookId: number }) {
  const [form, setForm] = useState({
    title: '',
    content: '',
    rating: 5,
    reviewerName: ''
  });

  const [addReview, { loading }] = useMutation(ADD_REVIEW, {
    // Refetch book details to get updated reviews and average
    refetchQueries: [
      { query: GET_BOOK_BY_ID, variables: { id: bookId } }
    ]
  });

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    
    const { data } = await addReview({
      variables: {
        input: { bookId, ...form }
      }
    });
    
    if (data?.addReview.error) {
      alert(data.addReview.error);
    } else {
      // Clear form on success
      setForm({ title: '', content: '', rating: 5, reviewerName: '' });
    }
  };

  return (
    <form onSubmit={handleSubmit} className="review-form">
      <h3>Write a Review</h3>
      
      <input
        value={form.reviewerName}
        onChange={(e) => setForm({...form, reviewerName: e.target.value})}
        placeholder="Your name"
        required
      />
      
      <input
        value={form.title}
        onChange={(e) => setForm({...form, title: e.target.value})}
        placeholder="Review title"
        required
      />
      
      <textarea
        value={form.content}
        onChange={(e) => setForm({...form, content: e.target.value})}
        placeholder="Your review..."
        required
      />
      
      <div className="rating-selector">
        <label>Rating: </label>
        {[1, 2, 3, 4, 5].map((r) => (
          <button
            key={r}
            type="button"
            onClick={() => setForm({...form, rating: r})}
            className={form.rating >= r ? 'active' : ''}
          >
            ★
          </button>
        ))}
      </div>
      
      <button type="submit" disabled={loading}>
        {loading ? 'Submitting...' : 'Submit Review'}
      </button>
    </form>
  );
}

Error Handling Patterns

Pattern 1: Payload-Based Errors (Recommended)

Return errors as part of the payload:

public record AddBookPayload(Book? Book, string? Error);
mutation {
  addBook(input: {...}) {
    book { id title }
    error  # null on success, error message on failure
  }
}

Pros:

  • Client can always check for errors

  • Type-safe error handling

  • Partial success possible (in batch operations)

Pattern 2: Throwing Exceptions

public async Task<Book> AddBook(AddBookInput input)
{
    if (!await context.Authors.AnyAsync(a => a.Id == input.AuthorId))
        throw new GraphQLException("Author not found");
    
    // ...
}

This results in a GraphQL error response:

{
  "data": null,
  "errors": [
    { "message": "Author not found" }
  ]
}

When to use: For unexpected errors, not business logic.

Handling Both in React

const [addBook] = useMutation(ADD_BOOK, {
  // Network/GraphQL errors
  onError: (error) => {
    console.error('Mutation failed:', error);
    toast.error('Something went wrong. Please try again.');
  },
  
  // Successful response (check for business logic errors)
  onCompleted: (data) => {
    if (data.addBook.error) {
      toast.error(data.addBook.error);
    } else {
      toast.success('Book added successfully!');
    }
  }
});

Optimistic Updates

Make your UI feel instant by updating before the server responds.

What Are Optimistic Updates?

  1. User clicks "Submit"

  2. UI immediately shows the result (optimistic)

  3. Mutation runs in background

  4. If successful: keep the optimistic result

  5. If failed: revert to previous state

Implementation

const [addReview] = useMutation(ADD_REVIEW, {
  // This is what we EXPECT the response to be
  optimisticResponse: {
    addReview: {
      __typename: 'AddReviewPayload',
      review: {
        __typename: 'Review',
        id: -1,  // Temporary ID
        title: form.title,
        content: form.content,
        rating: form.rating,
        reviewerName: form.reviewerName,
        createdAt: new Date().toISOString()
      },
      error: null
    }
  },
  
  // Update cache immediately with optimistic data
  update(cache, { data }) {
    // Add review to the book's reviews list in cache
    // ...
  }
});

Key points:

  • __typename is required for Apollo to identify types

  • Use temporary ID (server will return real one)

  • Cache updates twice: once with optimistic data, once with real data

Cache Management

Option 1: Refetch Queries

Simple but makes extra network requests:

const [addBook] = useMutation(ADD_BOOK, {
  refetchQueries: [
    'GetBooks',  // Refetch by operation name
    { query: GET_STATISTICS }  // Or by query document
  ]
});

Option 2: Manual Cache Update

More efficient - no extra requests:

const [addBook] = useMutation(ADD_BOOK, {
  update(cache, { data: { addBook } }) {
    // Read current cache
    const existing = cache.readQuery({ 
      query: GET_BOOKS,
      variables: { first: 10 }
    });
    
    if (existing && addBook.book) {
      // Write updated cache
      cache.writeQuery({
        query: GET_BOOKS,
        variables: { first: 10 },
        data: {
          books: {
            ...existing.books,
            nodes: [addBook.book, ...existing.books.nodes],
            totalCount: existing.books.totalCount + 1
          }
        }
      });
    }
  }
});

Key Takeaways

  1. Mutations modify data - Use mutation keyword (required, unlike query)

  2. Input types - Group mutation parameters cleanly

  3. Payload pattern - Return both data AND potential errors

  4. Partial updates - Only update fields that are provided

  5. Sequential execution - Mutations run in order, not parallel

  6. Cache management - Use refetchQueries or update to keep UI in sync

  7. Optimistic updates - Show changes instantly for better UX

  8. Validation - Always validate on the server

  9. Code: graphql-dotnet-react-from-zero-to-production

What's Next?

In Part 4: Subscriptions & Real-time, we'll learn how to:

  • Set up WebSocket connections

  • Create subscriptions for real-time updates

  • Handle subscription events in React

  • Build a live review feed

Exercises

  1. Implement an updateAuthor mutation

  2. Add validation to prevent books with future publication years

  3. Create a toggleAvailability mutation that flips the isAvailable flag

  4. Implement optimistic updates for deleting a review

  5. Add a confirmation dialog before delete operations

See you!

Part 4: Real-Time Data with web sockets

Part 5: Advanced Patterns (Pagination, Errors, JWT & Testing)