Implementing Optimistic Updates in Next.js for Smooth User Experience
Learn how to implement optimistic updates in Next.js using Prisma and Server Actions for seamless CRUD operations that feel instant to users.
Optimistic updates are a powerful technique that makes your applications feel lightning-fast by updating the UI immediately, before waiting for server confirmation. In this comprehensive guide, we'll build a collection management system that demonstrates optimistic updates for create, update, and delete operations using Next.js, Prisma, and Server Actions.
What Are Optimistic Updates?
Optimistic updates assume that server operations will succeed and immediately update the UI. If the operation fails, the UI reverts to its previous state. This creates a smooth, responsive user experience that feels instant, even on slower connections.
Benefits:
- Immediate visual feedback
- Better perceived performance
- Enhanced user experience
- Reduced loading states
Project Setup
Let's start by setting up our project with the necessary dependencies and database schema.
Dependencies
First, install the required packages:
pnpm add prisma @prisma/client react-hook-form lucide-react
npm install -D @types/node typescript
Database Schema
Here's our Prisma schema for the collections system:
model MeetingCollection {
id String @id @default(auto()) @map("_id") @db.ObjectId
item String
amount Float
category CollectionCategory @default(PLEDGE)
contact String?
collectionDate DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
enum CollectionCategory {
PLEDGE
CASH
}
Database Connection
Create a database connection utility:
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const db = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = db;
Server Actions
Server Actions handle our backend operations. Let's implement create, update, and delete functions:
import { db } from "@/prisma/db";
import { revalidatePath } from "next/cache";
export type CollectionFormData = {
item: string;
contact?: string | null;
amount: number;
collectionDate: Date;
category: "PLEDGE" | "CASH";
};
// Create Collection
export async function createCollection(formData: CollectionFormData) {
try {
const collection = await db.meetingCollection.create({
data: {
amount: Number(formData.amount),
item: formData.item,
collectionDate: new Date(formData.collectionDate) || new Date(),
category: formData.category,
contact: formData.contact,
},
});
revalidatePath("/collections");
return { success: true, data: collection };
} catch (error) {
console.error("Failed to create collection:", error);
return { success: false, error: "Failed to create collection" };
}
}
// Update Collection
export async function updateCollection(id: string, data: CollectionFormData) {
try {
const updatedCollection = await db.meetingCollection.update({
where: { id },
data: {
item: data.item,
contact: data.contact,
amount: data.amount,
collectionDate: data.collectionDate,
category: data.category,
},
});
revalidatePath("/collections");
return { success: true, data: updatedCollection };
} catch (error) {
console.error("Error updating collection:", error);
return { success: false, error: "Failed to update collection" };
}
}
// Delete Collection
export async function deleteCollection(id: string) {
try {
await db.meetingCollection.delete({
where: { id },
});
revalidatePath("/collections");
return { success: true };
} catch (error) {
console.error("Failed to delete collection:", error);
return { success: false, error: "Failed to delete collection" };
}
}
Implementing Optimistic Updates
Now let's build the main component with optimistic updates for all CRUD operations:
"use client";
import React, { useState } from "react";
import { useForm } from "react-hook-form";
import { MeetingCollection } from "@prisma/client";
import {
createCollection,
deleteCollection,
updateCollection,
CollectionFormData
} from "@/actions/collection";
export default function CollectionManager({
collections: initialCollections,
}: {
collections: MeetingCollection[];
}) {
const [collections, setCollections] = useState(initialCollections);
const [isSubmitting, setIsSubmitting] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const form = useForm<CollectionFormData>();
const editForm = useForm<CollectionFormData>();
// Optimistic Create
const onSubmit = async (data: CollectionFormData) => {
setIsSubmitting(true);
// Create optimistic item
const optimisticCollection = {
id: Date.now().toString(), // Temporary ID
...data,
amount: Number(data.amount),
contact: data.contact || null,
createdAt: new Date(),
updatedAt: new Date(),
collectionDate: data.collectionDate || new Date(),
};
// Optimistic update - add immediately
setCollections(prev => [optimisticCollection, ...prev]);
try {
const result = await createCollection(data);
if (!result.success) {
throw new Error(result.error);
}
// Replace optimistic item with real item
if (result.data) {
setCollections(prev =>
prev.map(item =>
item.id === optimisticCollection.id ? result.data : item
)
);
}
form.reset();
} catch (error) {
// Revert optimistic update on error
setCollections(prev =>
prev.filter(item => item.id !== optimisticCollection.id)
);
console.error("Failed to create collection:", error);
} finally {
setIsSubmitting(false);
}
};
// Optimistic Update
const handleEdit = (collection: MeetingCollection) => {
setEditingId(collection.id);
editForm.reset({
item: collection.item,
contact: collection.contact,
amount: collection.amount,
collectionDate: new Date(collection.collectionDate || new Date()),
category: collection.category,
});
};
const onEditSubmit = async (data: CollectionFormData) => {
if (!editingId) return;
setIsSubmitting(true);
const processedData = {
...data,
amount: Number(data.amount),
contact: data.contact || null,
};
// Store original item for potential rollback
const originalItem = collections.find(c => c.id === editingId);
// Optimistic update
setCollections(prev =>
prev.map(c =>
c.id === editingId
? { ...c, ...processedData, updatedAt: new Date() }
: c
)
);
try {
const result = await updateCollection(editingId, processedData);
if (!result.success) {
throw new Error(result.error);
}
setEditingId(null);
} catch (error) {
// Revert to original state on error
if (originalItem) {
setCollections(prev =>
prev.map(c => c.id === editingId ? originalItem : c)
);
}
console.error("Failed to update collection:", error);
} finally {
setIsSubmitting(false);
}
};
// Optimistic Delete
const handleDelete = async (id: string) => {
// Store item for potential rollback
const itemToDelete = collections.find(c => c.id === id);
// Optimistic update - remove immediately
setCollections(prev => prev.filter(c => c.id !== id));
try {
const result = await deleteCollection(id);
if (!result.success) {
throw new Error("Delete failed");
}
} catch (error) {
// Revert optimistic update on error
if (itemToDelete) {
setCollections(prev => [itemToDelete, ...prev]);
}
console.error("Failed to delete collection:", error);
}
};
return (
<div className="max-w-4xl mx-auto p-6">
<h1 className="text-3xl font-bold mb-8">Collection Manager</h1>
{/* Add Collection Form */}
<form onSubmit={form.handleSubmit(onSubmit)} className="mb-8 p-6 bg-gray-50 rounded-lg">
<h2 className="text-xl font-semibold mb-4">Add New Collection</h2>
<div className="grid md:grid-cols-2 gap-4 mb-4">
<div>
<label className="block text-sm font-medium mb-1">Item</label>
<input
{...form.register("item", { required: "Item is required" })}
className="w-full p-2 border rounded-md"
placeholder="Item description"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Contact (Optional)</label>
<input
{...form.register("contact")}
className="w-full p-2 border rounded-md"
placeholder="Phone or contact info"
/>
</div>
</div>
<div className="grid md:grid-cols-3 gap-4 mb-4">
<div>
<label className="block text-sm font-medium mb-1">Amount</label>
<input
{...form.register("amount", { required: "Amount is required", min: 0 })}
type="number"
step="0.01"
className="w-full p-2 border rounded-md"
placeholder="0.00"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Category</label>
<select
{...form.register("category")}
className="w-full p-2 border rounded-md"
>
<option value="PLEDGE">Pledge</option>
<option value="CASH">Cash</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">Collection Date</label>
<input
{...form.register("collectionDate", { required: "Date is required" })}
type="date"
className="w-full p-2 border rounded-md"
/>
</div>
</div>
<button
type="submit"
disabled={isSubmitting}
className="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 disabled:opacity-50"
>
{isSubmitting ? "Adding..." : "Add Collection"}
</button>
</form>
{/* Collections List */}
<div className="space-y-4">
<h2 className="text-xl font-semibold">Collections ({collections.length})</h2>
{collections.length === 0 ? (
<p className="text-gray-500 text-center py-8">No collections found</p>
) : (
collections.map((collection) => (
<div key={collection.id} className="bg-white p-4 border rounded-lg shadow-sm">
{editingId === collection.id ? (
// Edit Mode
<form onSubmit={editForm.handleSubmit(onEditSubmit)} className="space-y-4">
<div className="grid md:grid-cols-2 gap-4">
<input
{...editForm.register("item", { required: true })}
className="w-full p-2 border rounded-md"
/>
<input
{...editForm.register("contact")}
className="w-full p-2 border rounded-md"
placeholder="Contact (optional)"
/>
</div>
<div className="grid md:grid-cols-3 gap-4">
<input
{...editForm.register("amount", { required: true, min: 0 })}
type="number"
step="0.01"
className="w-full p-2 border rounded-md"
/>
<select
{...editForm.register("category")}
className="w-full p-2 border rounded-md"
>
<option value="PLEDGE">Pledge</option>
<option value="CASH">Cash</option>
</select>
<input
{...editForm.register("collectionDate", { required: true })}
type="date"
className="w-full p-2 border rounded-md"
/>
</div>
<div className="flex gap-2">
<button
type="submit"
className="bg-green-600 text-white px-3 py-1 rounded text-sm hover:bg-green-700"
>
Save
</button>
<button
type="button"
onClick={() => setEditingId(null)}
className="bg-gray-600 text-white px-3 py-1 rounded text-sm hover:bg-gray-700"
>
Cancel
</button>
</div>
</form>
) : (
// Display Mode
<div className="flex justify-between items-start">
<div className="flex-1">
<h3 className="font-semibold text-lg">{collection.item}</h3>
<p className="text-gray-600">
{collection.contact && `Contact: ${collection.contact} • `}
Amount: UGX {collection.amount.toLocaleString()} •
Category: {collection.category}
</p>
<p className="text-sm text-gray-500">
{collection.collectionDate &&
`Collection: ${new Date(collection.collectionDate).toLocaleDateString()}`
}
</p>
</div>
<div className="flex gap-2">
<button
onClick={() => handleEdit(collection)}
className="bg-blue-600 text-white px-3 py-1 rounded text-sm hover:bg-blue-700"
>
Edit
</button>
<button
onClick={() => handleDelete(collection.id)}
className="bg-red-600 text-white px-3 py-1 rounded text-sm hover:bg-red-700"
>
Delete
</button>
</div>
</div>
)}
</div>
))
)}
</div>
</div>
);
}
Key Optimistic Update Patterns
1. Create Operations
// Generate optimistic item with temporary ID
const optimisticItem = {
id: Date.now().toString(), // Temporary ID
...formData,
createdAt: new Date(),
updatedAt: new Date(),
};
// Add immediately to state
setItems((prev) => [optimisticItem, ...prev]);
try {
const result = await createItem(formData);
// Replace with real item from server
setItems((prev) =>
prev.map((item) => (item.id === optimisticItem.id ? result.data : item))
);
} catch (error) {
// Remove optimistic item on error
setItems((prev) => prev.filter((item) => item.id !== optimisticItem.id));
}
2. Update Operations
// Store original for rollback
const original = items.find((item) => item.id === id);
// Apply optimistic update
setItems((prev) =>
prev.map((item) => (item.id === id ? { ...item, ...updates } : item))
);
try {
await updateItem(id, updates);
} catch (error) {
// Revert to original on error
if (original) {
setItems((prev) => prev.map((item) => (item.id === id ? original : item)));
}
}
3. Delete Operations
// Store item for potential rollback
const itemToDelete = items.find((item) => item.id === id);
// Remove immediately
setItems((prev) => prev.filter((item) => item.id !== id));
try {
await deleteItem(id);
} catch (error) {
// Restore item on error
if (itemToDelete) {
setItems((prev) => [itemToDelete, ...prev]);
}
}
Error Handling Best Practices
- Always store originals before optimistic updates for potential rollbacks
- Use try-catch blocks to handle server errors gracefully
- Provide user feedback when operations fail
- Consider network conditions - show retry options for failed operations
- Log errors appropriately for debugging while maintaining user experience
Advanced Techniques
Conflict Resolution
When multiple users edit the same data, implement conflict resolution:
try {
const result = await updateItem(id, updates);
if (result.conflict) {
// Show conflict resolution UI
showConflictDialog(result.serverVersion, updates);
}
} catch (error) {
// Handle update conflicts
}
Optimistic Update Queue
For complex applications, consider implementing an update queue:
const updateQueue = useRef<
Array<{
id: string;
operation: "create" | "update" | "delete";
data: any;
rollback: () => void;
}>
>([]);
Performance Considerations
- Debounce rapid updates to avoid excessive server calls
- Use unique temporary IDs to prevent conflicts
- Clean up failed operations to prevent memory leaks
- Consider using React Query or SWR for more sophisticated caching strategies
Conclusion
Optimistic updates significantly improve user experience by making applications feel instant and responsive. The key is to:
- Update the UI immediately when users perform actions
- Handle errors gracefully by reverting changes when operations fail
- Provide clear feedback about the state of operations
- Test thoroughly including error scenarios
This pattern works exceptionally well with Next.js Server Actions and modern state management, creating smooth, professional applications that users love to interact with.
Remember: optimistic updates are about perceived performance as much as actual performance. Users will appreciate the immediate feedback, even if the operation takes a moment to complete on the server.