Optimistic Updates Using InfiniteQueries

Have you ever pondered the swift response you receive when you click the "like" button on platforms such as Twitter or Instagram? Is the network truly lightning-fast, processing your like request instantaneously? In reality, what occurs behind the scenes is known as "optimistic updates."

Optimistic updates involve updating the user interface (UI) with the assumption that the request has successfully been completed. If, by chance, the request fails, the UI reverts to its previous state. Let's explore how this concept can be effectively implemented.

For this purpose, we'll leverage TanStack Query, a library for managing server-side state within the user interface. Now, let's delve into how we can apply this in the context of a social media application, much like Twitter.

On Twitter, the user experiences infinite scrolling, where additional posts are loaded as they approach the end of their scroll. TanStack Query provides us "useInfiniteQuery" hook, which enables us to seamlessly implement this infinite scroll feature.

Imagine you have a Feed Component that mirrors the functionality of a Twitter feed, and within this feed, you have individual posts represented by a Post Component. To enable the infinite scroll feature, we'll use the Intersection Observer API, a feature provided by modern web browsers. This API allows us to efficiently monitor and respond to elements' visibility within the DOM.


async function fetchPosts ({ pageParam }: { pageParam: number} )  {
    const res = await axios.get('/api/posts?cursor=' + pageParam)
    return { posts: res.data.posts, next: res.data.next }
}

// Feed component which represents Twitter Feed
function Feed() {
    const { data, error, fetchNextPage, status } = useInfiniteQuery({
        queryKey: ['feed'],
        queryFn: fetchPosts,
        initialPageParam: 0,
        getNextPageParam: (lastPage) => {return lastPage.next}
    });
    const bottomRef = useRef(null);

    useEffect(() => {
        const observer = new IntersectionObserver(handleIntersection);
        const elementRef = bottomRef.current;
        if (elementRef) observer.observe(elementRef);
        return () => {
            if (elementRef) observer.unobserve(elementRef);
        };
    }, []);

    function handleIntersection(entries: any) {
        const firstEntry = entries[0];
        if (firstEntry.isIntersecting) {
            console.log("hello");
        }
    }

    return (
            {data?.pages.map((page, index: number) => {
                return (
                    <React.Fragment key={index}>
                        <div className="flex flex-col w-full">
                            {page.posts.map((post: any) => (
                                <Post key={post.id} post={post} />
                            ))}
                        </div>
                        {index == data.pages.length - 1 && (
                            <div ref={bottomRef} className="h-10"></div>
                        )}
                    </React.Fragment>
                );
            })}
    );
}

Here's how it works: The Intersection Observer operates asynchronously to verify whether the last post in your feed is within the viewport of the user's browser. When it determines that the post is indeed visible, it triggers the execution of the "fetchNextPage" method. This method is conveniently provided by the "useInfiniteQuery" hook to fetch the next set of posts.

Now that we have infinite scroll, let us look at how optimistic updates work.

Abstracting Mutation Logic with a Custom Hook

To enhance maintainability and code reusability, we'll encapsulate the mutation logic within a custom hook called useUpdatePost. This hook will return an object, allowing us to invoke the mutate method, passing it the updated post value with an incremented Like count.

Implementing the useUpdatePost Hook

The useUpdatePost hook will handle the mutation process, including interacting with the backend server to persist the updated post data. It will also manage the optimistic update, reflecting the like count increment in the UI before the server responds.

function updatePost(data: Post): Promise<Post> {
    return axios.patch(`/api/posts/${data.id}`, data);
}

export function useUpdatePost() {
    return useMutation({
        mutationFn: updatePost,
        onMutate: async (modifiedPost: Post) => {
            await queryClient.cancelQueries({ queryKey: ["feed"] });
            const previousInfiniteQueryData: InfiniteData<Array<Post>> = queryClient.getQueryData(['feed'])!

            queryClient.setQueryData<InfiniteData<Array<Post>>>(['feed'], (previousInfiniteQueryData: any) => {
                const newData = previousInfiniteQueryData?.pages.map((page: {posts: Post[]}) => ({
                    ...page,
                    posts: page.posts.map((post: Post) => {
                        if (post.id === modifiedPost.id) {
                            return modifiedPost
                        }
                        return post
                    })
                }))    

                return {
                    ...previousInfiniteQueryData,
                    pages: newData,
                }
            })            
            return { previousInfiniteQueryData }
        },
        onError: (err, modifiedPost, context) => {
            queryClient.setQueryData(
                ['feed'], context?.previousInfiniteQueryData,
            )
        },
        // Always refetch after error or success:
        onSettled: () => {
            queryClient.invalidateQueries({ queryKey: ['feed'] })
        },
    })
}

Here's a brief overview:

  1. mutationFn: updatePost: This is the function that will be called when the mutation is triggered. It's expected to be an async function that updates a post. In our case, we are increasing the like count.

  2. onMutate: async (modifiedPost: Post) => {...}: This function is called before the mutation function (updatePost). It cancels any ongoing queries related to the feed, gets the current data of the feed, and optimistically updates the feed in the cache to include the modified post.

  3. onError: (err, modifiedPost, context) => {...}: This function is called if the mutation function fails. It rolls back the optimistic update by setting the feed data in the cache back to what it was before the mutation.

  4. onSettled: () => {...}: This function is called after the mutation has either succeeded or failed. It invalidates the cache for the feed, causing any components that are displaying this data to re-fetch the latest data.

Integrating the useUpdatePost Hook

To integrate the useUpdatePost hook, we'll call the mutate method within the click event handler for the like button. This will trigger the mutation process and update the UI accordingly.

function Post({post}: {post: Post}) {
    const updatePostMutation = useUpdatePost();
    function handleLikeClick() {
        updatePostMutation.mutate({...post, likesCount: post.likesCount + 1});
    }

    return (
        <div>
                {/* Avatar */}
                {/* Post Body */}
                <Heart onClick={handleLikeClick} size={16} />
        </div>
    );
}

Upon clicking the "Like" button, the like count is instantly incremented and its color changes to red to indicate that the post has been liked. If an error occurs during the request, the UI changes are reverted (i.e., the like count is decremented by 1 and the color is restored to its original state). To observe this behavior, you can throw an error in the updatePost handler on the API side.

I hope this blog has been a valuable learning resource. Thank You