JB logo

Command Palette

Search for a command to run...

yOUTUBE
Blog
PreviousNext

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:

prisma/schema.prisma
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:

prisma/db.ts
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:

actions/collection.ts
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:

components/CollectionManager.tsx
"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

  1. Always store originals before optimistic updates for potential rollbacks
  2. Use try-catch blocks to handle server errors gracefully
  3. Provide user feedback when operations fail
  4. Consider network conditions - show retry options for failed operations
  5. 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

  1. Debounce rapid updates to avoid excessive server calls
  2. Use unique temporary IDs to prevent conflicts
  3. Clean up failed operations to prevent memory leaks
  4. 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:

  1. Update the UI immediately when users perform actions
  2. Handle errors gracefully by reverting changes when operations fail
  3. Provide clear feedback about the state of operations
  4. 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.