Spoeky
Spoeky

Reputation: 71

NextJS optimistic UI updates with useOptimistic not working

I'm trying to create a PostItem component for my web app. Im using NextJs 14, TypeScript, TailwindCSS and Prisma. For over 3 days now I have been trying to implement optimistic UI updates when a user likes or saves a post. The code below has a weird issue where when i like a post, its updated instantly on the UI with the like count increasing / decreasing but then as soon as the server request was made, it resets to what it was before, even though there was no error on the backend.

PostItem.tsx:

"use client";

import { Bookmark, Heart, MessageCircle } from "lucide-react";
import { UserAvatar } from "./UserAvatar";
import Link from "next/link";
import Image from "next/image";
import { AspectRatio } from "../shadcn/ui/aspect-ratio";
import { Skeleton } from "../shadcn/ui/skeleton";
import { formatDateToString } from "@/lib/utils";
import {
    deletePost,
    patchPostLike,
    patchPostSave,
} from "@/lib/actions/post.actions";
import { useState, useOptimistic } from "react";
import LoginModal from "./LoginModal";

interface PostItemProps {
    image: string;
    likes: string[];
    saves: string[];
    comments: number;
    id: string;
    author: { name: string; image: string; id: string };
    userId?: string;
    createdAt: Date;
}

function PostItem({
    image,
    likes,
    saves,
    comments,
    id,
    userId,
    author,
    createdAt,
}: PostItemProps) {
    const [serverLikes, setServerLikes] = useState(likes);
    const [serverSaves, setServerSaves] = useState(saves);
    const [serverLiked, setServerLiked] = useState(
        serverLikes.includes(userId || "")
    );
    const [serverSaved, setServerSaved] = useState(
        serverSaves.includes(userId || "")
    );
    const [optimisticLikes, setOptimisticLikes] = useOptimistic(serverLikes);

    const [optimisticLiked, setOptimisticLiked] = useOptimistic(serverLiked);
    const [optimisticSaved, setOptimisticSaved] = useOptimistic(serverSaved);

    const userURL = `/user/${author.name}`;
    const postURL = `/post/${id}`;

    const formattedDate = formatDateToString(createdAt);

    const handleLikeClick = async () => {
        if (!userId) return;
        try {
            const updatedLikes = serverLiked
                ? serverLikes.filter((like: string) => like !== userId)
                : [...serverLikes, userId];

            setOptimisticLikes(updatedLikes);
            setOptimisticLiked(updatedLikes.includes(userId));

            const res = await patchPostLike(id, userId, serverLikes);

            if (res.error != undefined) {
                return;
            }

            setServerLikes(res.success);
            setServerLiked(res.success.includes(userId));
        } catch (error: any) {
            console.error("Failed to update like:", error.message);
        }
    };

    const handleSaveClick = async () => {
        if (!userId) return;
        try {
            const updatedSaves = serverSaved
                ? serverSaves.filter((save: string) => save !== userId)
                : [...serverSaves, userId];

            setOptimisticSaved(updatedSaves.includes(userId));

            const res = await patchPostSave(id, userId, serverSaves);

            if (res.error != undefined) {
                return;
            }

            setServerSaves(res.success);
            setServerSaved(res.success.includes(userId));
        } catch (error: any) {
            console.error("Failed to update save:", error.message);
        }
    };

    return (
        <div className="p-3 pl-0">
            <div className="flex items-start">
                <Link href={userURL} className="flex items-center gap-x-3">
                    <UserAvatar user={author} />
                    <div>
                        <p className="text-sm">{author.name}</p>
                        <p className="text-xs text-muted-foreground">
                            {formattedDate}
                        </p>
                    </div>
                </Link>
            </div>
            <Link href={postURL}>
                <AspectRatio ratio={2 / 2.75} className="mt-3 bg-muted">
                    <Image
                        src={image}
                        alt={id}
                        fill
                        className="object-cover rounded-lg"
                    />
                </AspectRatio>
            </Link>
            <div className="flex items-center justify-between gap-3 mt-3">
                <div className="flex items-center gap-x-4">
                    <div className="flex items-center">
                        {userId !== undefined ? (
                            <Heart
                                className={`w-5 h-5 hover:cursor-pointer hover:opacity-70 ${
                                    optimisticLiked
                                        ? "text-destructive fill-destructive"
                                        : ""
                                }`}
                                onClick={handleLikeClick}
                            />
                        ) : (
                            <LoginModal>
                                <Heart className="w-5 h-5 hover:cursor-pointer hover:opacity-70" />
                            </LoginModal>
                        )}
                        <p className="ml-1 text-sm text-muted-foreground">
                            {optimisticLikes.length}
                        </p>
                    </div>
                    <div className="flex items-center">
                        <MessageCircle className="w-5 h-5" />
                        <p className="ml-1 text-sm text-muted-foreground hover:opacity-70">
                            {comments}
                        </p>
                    </div>
                </div>
                {userId !== undefined ? (
                    <Bookmark
                        className={`w-5 h-5 hover:cursor-pointer hover:opacity-70 ${
                            optimisticSaved
                                ? "text-yellow-400 fill-yellow-400"
                                : ""
                        }`}
                        onClick={handleSaveClick}
                    />
                ) : (
                    <LoginModal>
                        <Bookmark className="w-5 h-5 hover:cursor-pointer hover:opacity-70" />
                    </LoginModal>
                )}
            </div>
        </div>
    );
}

PostItem.Skeleton = function PostItemSkeleton() {
    return (
        <div>
            <div className="flex items-start">
                <div className="flex items-center gap-x-3">
                    <Skeleton className="w-12 h-12 rounded-full" />
                    <div className="space-y-2">
                        <Skeleton className="h-4 w-[80px]" />
                        <Skeleton className="h-4 w-[125px]" />
                    </div>
                </div>
            </div>
            <div>
                <AspectRatio ratio={2 / 2.75} className="mt-3">
                    <Skeleton className="w-full h-full rounded-lg" />
                </AspectRatio>
            </div>
        </div>
    );
};

export default PostItem;

post.actions.ts:

"use server";

export async function patchPostLike(
    postId: string,
    userId: string,
    likedBy: string[]
) {
    const user = await getCurrentUser();
    if (!user) return { error: "Unauthorized" };
    try {
        const isLiked = likedBy.some((like) => like === userId);
        const post = await client.post.update({
            where: {
                id: postId,
            },
            data: {
                likedBy: {
                    [isLiked ? "deleteMany" : "create"]: {
                        userId: userId,
                    },
                },
            },
            include: {
                likedBy: true,
            },
        });

        const likes = post.likedBy.map((i) => {
            return i.userId;
        });
        return { success: likes };
    } catch (error: any) {
        return { error: `Failed to add like to post: ${error.message}` };
    }
}

export async function patchPostSave(
    postId: string,
    userId: string,
    savedBy: string[]
) {
    const user = await getCurrentUser();
    if (!user) return { error: "Unauthorized" };
    try {
        const isSaved = savedBy.some((save) => save === userId);
        const post = await client.post.update({
            where: { id: postId },
            data: {
                savedBy: {
                    [isSaved ? "deleteMany" : "create"]: {
                        userId: userId,
                    },
                },
            },
            include: { savedBy: true },
        });

        const saves = post.savedBy.map((i) => {
            return i.userId;
        });
        return { success: saves };
    } catch (error: any) {
        return { error: `Failed to save post: ${error.message}` };
    }
}

I tried various approaches like not having a serverLikes state but rather having the "fallback" value of the useOptimistic hook be the likes value passed in through props. However, this does not work as the likes wont be able to update after the request was made from inside of the component.

Upvotes: 1

Views: 978

Answers (0)

Related Questions