In web applications, the concept of Optimistic UI helps improve the user experience by instantly reflecting UI changes before the server processes the request. Instead of waiting for a backend response, the UI updates immediately, giving users a faster and more responsive feel.
In this blog post, we’ll walk through creating a Like button in a Next.js application using Server Actions. We will implement an Optimistic UI update without using the useOptimistic
hook, but manually manage state changes while the server action is being processed.
What is Optimistic UI?
Optimistic UI is an approach where you assume the operation will succeed and reflect the UI changes immediately. If the operation fails, you can revert the changes, but in most cases, the server responds successfully, making the experience more seamless.
The Goal: Creating a Like Button
We'll build a like button with a counter that will update optimistically when the user likes or unlikes a post. The backend logic will be handled using a Next.js server action, and the frontend will update the UI optimistically.
Backend: Server Action for Toggling Likes
Here is the backend code for the `toggleLike` function. This function checks if the user has already liked a post and either adds or removes the like accordingly.
export const toggleLike = async (postId: string, state: boolean) => {
const session = await auth();
if (!session) {
return { success: false, message: "Login first" };
}
try {
await connectDB();
const userId = session?.user?.id as string;
// Check if the user has already liked the post
const like = await Like.findOne({ postId, userId });
if (like && !state) {
// If liked and unchecking, remove the like
await Like.findByIdAndDelete(like._id);
revalidatePath(`/`); // Revalidate the page for fresh data
return { success: true, message: "Post unliked", like: false };
} else {
// If not liked and checking, add a like
await Like.create({ postId, userId });
revalidatePath(`/`);
return { success: true, message: "Post liked", like: true };
}
} catch (error) {
console.log("Error in toggleLike:", error);
return { success: false, message: "Something went wrong" };
}
};
Explanation of the Backend Logic:
1. auth()
: Authenticates the user. If the user is not logged in, it returns a message to prompt login.
2. Like.findOne()
: Checks if the user has already liked the post.
3. Toggle the like:
- If the post is already liked and the checkbox is unchecked, the like is removed.
- If the post is not liked and the checkbox is checked, a new like is added.
4. revalidatePath()
: Ensures that the page fetches the most recent data after liking or unliking the post.
Frontend: Optimistic Like Button
Now, let's move on to the frontend part, where we'll implement the Like button with an Optimistic UI update. We'll instantly reflect the like/unlike state and update the like count, even before receiving the response from the server.
"use client";
import React, { useState } from "react";
import { toggleLike } from "@/actions/likes";
import DevButton from "../dev-components/dev-button";
import { BiLike, BiSolidLike } from "react-icons/bi";
import { useRouter } from "next/navigation";
type Props = {
postId: string;
likeCount?: number;
isLiked: boolean;
disabled?: boolean;
};
const LikeButton = ({
postId,
likeCount = 0,
isLiked = false,
disabled
}: Props) => {
const [likeCnt, setLikeCnt] = useState(likeCount); // Store the current like count in state
const router = useRouter();
const handleLike = async (e: React.ChangeEvent<HTMLInputElement>) => {
if (disabled) {
// If the user is not logged in, redirect to the auth page
e.target.checked = false;
router.push("/auth");
return;
}
try {
const state = e.target.checked; // Get the checkbox checked state
setLikeCnt(likeCnt + (state ? 1 : -1)); // Optimistically update the like count
// Call the server action to update the backend
await toggleLike(postId, state);
} catch (error) {
console.log("Error in handleLike:", error);
}
};
return (
<DevButton rounded="full" variant="flat" className="relative !p-1 !px-3 gap-2 text-xl">
<input
onChange={(e) => handleLike(e)}
type="checkbox"
id="like"
className="cursor-pointer peer hidden"
defaultChecked={isLiked} // Initial state is based on whether the user has liked the post
/>
<label htmlFor="like" className="cursor-pointer select-none absolute inset-0" />
<BiSolidLike className="peer-checked:block hidden" /> {/* Solid like icon when checked */}
<BiLike className="peer-checked:hidden block" /> {/* Hollow like icon when unchecked */}
<p className="text-sm">{likeCnt}</p> {/* Display the updated like count */}
</DevButton>{/* Devbutton is a button component */}
);
};
export default React.memo(LikeButton); // Memoize to prevent unnecessary re-renders
Explanation of the Frontend Logic:
1. Props:
- postId
: The ID of the post being liked.
- likeCount
: The current number of likes.
- isLiked
: Whether the post is liked by the user.
- disabled
: If the user is not logged in, the like button will redirect to the login page.
2. State:
- likeCnt
: Stores the like count, initially set to the current number of likes.
3. Optimistic Update:
- setLikeCnt(likeCnt + (state ? 1 : -1))
: The like count is immediately updated by 1 if the checkbox is checked (liked) and decreased by 1 if unchecked (unliked). This happens before the server response.
4. Handling Authentication:
- If the user is not logged in, the checkbox is disabled, and they are redirected to the /auth
page.
5. Icons:
- BiSolidLike
: Displays when the post is liked.
- BiLike
: Displays when the post is unliked.
Using the LikeButton Component
To use the LikeButton
in your Next.js app, pass the necessary props such as postId
, likeCount
, isLiked
, and disabled
. Here’s an example of how you might include this component:
<LikeButton
postId="123"
likeCount={10}
isLiked={true}
disabled={false}
/>
Conclusion
This example shows how to implement an Optimistic UI update for a Like button in Next.js using server actions. The key is updating the UI instantly while the request is still being processed by the server, providing a smooth user experience. The backend logic handles the actual database update, and the frontend reflects the changes immediately for the user.