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
| Aspect | Query | Mutation |
|---|
| Purpose | Read data | Modify data |
| Side effects | None | Expected |
| Execution | Can run in parallel | Run one at a time, in order |
| Keyword | query (or omit) | mutation (required) |
| Analogy | SELECT / GET | INSERT/UPDATE/DELETE / POST/PUT/DELETE |
Why Sequential Execution Matters
Imagine these two mutations:
Create a user
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:
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:
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?
User clicks "Submit"
UI immediately shows the result (optimistic)
Mutation runs in background
If successful: keep the optimistic result
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
Mutations modify data - Use mutation keyword (required, unlike query)
Input types - Group mutation parameters cleanly
Payload pattern - Return both data AND potential errors
Partial updates - Only update fields that are provided
Sequential execution - Mutations run in order, not parallel
Cache management - Use refetchQueries or update to keep UI in sync
Optimistic updates - Show changes instantly for better UX
Validation - Always validate on the server
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
Implement an updateAuthor mutation
Add validation to prevent books with future publication years
Create a toggleAvailability mutation that flips the isAvailable flag
Implement optimistic updates for deleting a review
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)