Part 12 — Develop Delete Post Feature in Frontend

Loi Le
7 min readApr 15, 2024

--

This is a part of the Tutorial to build a blog platform with Elixir Phoenix and Next.js that help you to develop a web application from scratch using modern technologies such as Elixir Phoenix, Next.js, and more.

Index | < Pre | Next >

We have finished the update function for my posts in the previous part. Now, we will continue to work on the front end side of the delete post feature.

The first step is to create the hook in src/app/hooks/useDeletePost.ts

import { useMutation, useQueryClient } from "react-query";
import axios from "../axios";
import { QUERY_MY_POSTS } from "./useQueryMyPostList";
import { Post } from "../models/posts/types";

interface DeleteParams {
id: string;
accessToken: string;
}

const onMutate = async (params: DeleteParams) => {
const { id, accessToken } = params;

await axios.delete(`/api/my-posts/${id}`, {
headers: {
Authorization: accessToken,
},
});

return {
id,
};
};

const useDeletePost = (
onSuccess?: (
data: { id: string },
variables: DeleteParams,
context: any
) => void,
onError?: (error: any, variables: DeleteParams, context: any) => void
) => {
const queryClient = useQueryClient();

return useMutation(onMutate, {
onSuccess: (data, variables, context) => {
const postList = queryClient.getQueryData<Post[]>(QUERY_MY_POSTS);

if (postList) {
queryClient.setQueryData(
[QUERY_MY_POSTS],
postList.filter((item) => item?.id !== data?.id)
);
}
onSuccess && onSuccess(data, variables, context);
},
onError,
});
};

export default useDeletePost;

An important thing to pay attention to is the onSuccess function params. We used react-query to manage the server state in this application. React-query helps us to cache the information on the client side. When we delete a post, we could fetch the post list again to update it with the latest data. However, a better option is to modify the client cache of react-query, which will improve the performance by avoiding unnecessary server requests. To do this, we need to get the cache data first:

const postList = queryClient.getQueryData<Post[]>(QUERY_MY_POSTS);

Next, we modify the cache to reflect the new list without the deleted post:

if (postList) {
queryClient.setQueryData(
[QUERY_MY_POSTS],
postList.filter((item) => item?.id !== data?.id)
);
}

We have completed the hook for deleting posts. Now, let’s move on to the UI. Take a look at the function onDelete in src/app/(authenticated-pages)/my-posts/page.tsx

const onDelete = (id: string) => () => {
// implement later
};

We could use the useDeletePost hook to handle the deletion logic in this function. But that would not be a good idea. The post would be deleted if the user clicks the delete button by mistake. That would be very risky. Therefore, we should follow the best practices and create a confirmation modal. First, we need to create src/app/components/atoms/Modal/index.tsx

import { Dialog, Transition } from "@headlessui/react";
import { XMarkIcon } from "@heroicons/react/24/outline";
import { Fragment } from "react";

const Modal: React.FC<{
children: React.ReactElement | React.ReactElement[];
open: boolean;
onClose: () => void;
}> = ({ children, open, onClose }) => {
return (
<Transition.Root show={open} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={onClose}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
</Transition.Child>

<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-white px-4 pb-4 pt-5 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-sm sm:p-6">
<div className="absolute right-0 top-0 hidden pr-4 pt-4 sm:block">
<button
type="button"
className="rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
onClick={onClose}
>
<span className="sr-only">Close</span>
<XMarkIcon className="h-6 w-6" aria-hidden="true" />
</button>
</div>
{children}
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};

export default Modal;

We used the Dialog and Transition components from headlessui to build this Modal.

Then, we can use this Modal to create the confirmation Modal. Let’s create src/app/(authenticated-pages)/my-posts/DeletePostModal/index.tsx

import Button from "@/app/components/atoms/Button";
import Modal from "@/app/components/atoms/Modal";
import { Dialog } from "@headlessui/react";
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";

interface DeletePostModalProps {
open: boolean;
isLoading: boolean;
onClose: () => void;
onDelete: () => void;
}

const DeletePostModal: React.FC<DeletePostModalProps> = ({
open,
isLoading,
onClose,
onDelete,
}) => {
return (
<Modal open={open} onClose={onClose}>
<div>
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-red-100">
<ExclamationTriangleIcon
className="h-6 w-6 text-red-600"
aria-hidden="true"
/>
</div>
<div className="mt-3 text-center sm:mt-5">
<Dialog.Title
as="h3"
className="text-base font-semibold leading-6 text-gray-900"
>
Are you sure you want to delete this post?
</Dialog.Title>
<div className="mt-2">
<p className="text-sm text-gray-500">
This action cannot be undone. The data will be permanently removed
forever.
</p>
</div>
</div>
</div>
<div className="mt-5 sm:mt-6">
<Button
variant="redSolid"
className="!w-full"
onClick={onDelete}
loading={isLoading}
>
Delete
</Button>
</div>
</Modal>
);
};

export default DeletePostModal;

We put this modal in src/app/(authenticated-pages)/my-posts/ folder because it is specific to the my posts context. This is the only context where a post can be deleted. We need 4 params for this modal: open that determines whether the modal is visible or not, isLoading that indicates the deletion state, onClose that closes the modal, and onDelete that confirms the user’s intention to delete the post.

We can now apply the DeletePostModal component in src/app/(authenticated-pages)/my-posts/page.tsx

"use client";

import PostCard from "@/app/components/PostCard";
import Button from "@/app/components/atoms/Button";
import ErrorMessage from "@/app/components/atoms/ErrorMessage";
import Loading from "@/app/components/atoms/Loading";
import useDeletePost from "@/app/hooks/useDeletePost";
import useQueryMyPosts from "@/app/hooks/useQueryMyPostList";
import { useSession } from "next-auth/react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { toast } from "react-toastify";
import DeletePostModal from "./DeletePostModal";

const MyPosts: React.FC = () => {
const router = useRouter();
const { data: session } = useSession();

const [confirmDeleteId, setConfirmDeleteId] = useState<string | undefined>(
undefined
);

const { data: posts, isLoading, isError } = useQueryMyPosts();
const { mutate: onDeletePost, isLoading: isDeleting } = useDeletePost(
() => {
setConfirmDeleteId(undefined);
toast.success("Post deleted successfully");
},
() => {
toast.error("Something went wrong while deleting post");
}
);

if (isLoading)
return (
<div className="my-24">
<Loading />
</div>
);

if (isError) return <ErrorMessage />;

const onUpdate = (id: string) => () => router.push(`/update-post/${id}`);
const onDelete = (id: string) => () => setConfirmDeleteId(id);
const onConfirmDelete = () => {
if (!confirmDeleteId || !session?.accessToken) return;

onDeletePost({
id: confirmDeleteId,
accessToken: session?.accessToken,
});
};

return (
<>
<div className="bg-white py-12">
<div className="mx-auto max-w-7xl px-6 lg:px-8">
<h2 className="text-3xl font-bold tracking-tight text-gray-900 sm:text-4xl text-center">
My Posts
</h2>
{!posts || posts?.length === 0 ? (
<div className="text-center">
<div className="mt-6 text-gray-600 text-lg text-center">
There is no posts created. Please create the first one.
</div>
<Button
className="mt-4"
onClick={() => router.push("/create-post")}
>
Create
</Button>
</div>
) : (
<div className="mt-12 mx-auto grid max-w-7xl grid-cols-1 gap-x-8 gap-y-16 lg:mx-0 lg:max-w-none lg:grid-cols-3">
{posts?.map((post) => (
<PostCard
key={post.id}
{...post}
onUpdate={onUpdate(post.id)}
onDelete={onDelete(post.id)}
/>
))}
</div>
)}
</div>
</div>
<DeletePostModal
open={!!confirmDeleteId}
isLoading={isDeleting}
onClose={() => setConfirmDeleteId(undefined)}
onDelete={onConfirmDelete}
/>
</>
);
};

export default MyPosts;

Let’s see how this works. First, we create a new state:

const [confirmDeleteId, setConfirmDeleteId] = useState<string | undefined>(
undefined
);

The state confirmDeleteId stores the id of the post that the user wants to delete and is waiting for confirmation

Then we use the hook useDeletePost

const { mutate: onDeletePost, isLoading: isDeleting } = useDeletePost(
() => {
setConfirmDeleteId(undefined);
toast.success("Post deleted successfully");
},
() => {
toast.error("Something went wrong while deleting post");
}
);

We handle both success and error cases. For the success case, we set the confirmDeleteId to undefined and show a success message. For the error case, we just show an error message.

Then, we modify the onDelete function to assign the confirmDeleteId with the id of the post that the user wants to delete.

const onDelete = (id: string) => () => setConfirmDeleteId(id);

Then, we create two more functions onConfirmDelete and onCancelDelete

const onConfirmDelete = () => {
if (!confirmDeleteId || !session?.accessToken) return;

onDeletePost({
id: confirmDeleteId,
accessToken: session?.accessToken,
});
};
const onCancelDelete = () => setConfirmDeleteId(undefined);

for onConfirmDelete, we check if the confirmDeleteId and the accessToken are present, then we call the onDeletePost from the useDeletePost hook.

Then we add the DeletePostModal to our page:

// ...
<DeletePostModal
open={!!confirmDeleteId}
isLoading={isDeleting}
onClose={onCancelDelete}
onDelete={onConfirmDelete}
/>
// ...

The modal only opens when the confirmDeleteId is not undefined, and we pass the onClose and onDelete params with the onCancelDelete and onConfirmDelete functions.

Now we can try the delete feature at http://localhost:3000/my-page

After you click the Delete button, you will see the confirmation modal

Once you click the Delete button, the post will be removed successfully

Well done! We have finished the delete post feature. This completes all of the functionalities we planned to build in this tutorial. In the next part, we will review what we have learned and I will give you some advice on how to continue with Elixir Phoenix and Nextjs

Index | < Pre | Next>

--

--