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.
In this part, we will work on the update and delete post functionality. We will rely on the APIs that we built in the previous part. Let’s begin with the update post feature. As always, we will implement the hook first. We will create a new hook called src/app/hooks/useUpdatePost.ts
import { useMutation } from "react-query";
import axios from "../axios";
import { Post } from "../models/posts/types";
export type UpdatePostParams = Pick<
Post,
"id" | "title" | "description" | "content"
> & {
accessToken?: string;
};
const onMutate = async (params: UpdatePostParams) => {
const { id, title, description, content, accessToken } = params;
const response = await axios.put<{
data: Post;
}>(
`/api/my-posts/${id}`,
{
data: {
title,
description,
content
},
},
{
headers: {
Authorization: accessToken,
},
}
);
return response.data?.data;
};
const useUpdateMyPost = (
onSuccess?: (
data: Post | null | undefined,
variables: UpdatePostParams,
context: any
) => void,
onError?: (error: any, variables: UpdatePostParams, context: any) => void
) => {
return useMutation(onMutate, {
onSuccess,
onError,
});
};
export default useUpdateMyPost;
To update the post, we just need to call the update API with the required access_token. Then we make the update post page at src/app/(authenticated-pages)/update-post/[postId]/page.tsx
"use client";
import ErrorMessage from "@/app/components/atoms/ErrorMessage";
import Loading from "@/app/components/atoms/Loading";
import useQueryPostDetail from "@/app/hooks/useQueryPostDetail";
import useUpdatePost from "@/app/hooks/useUpdatePost";
import { useParams, useRouter } from "next/navigation";
import { toast } from "react-toastify";
const UpdatePost: React.FC = () => {
const { postId } = useParams() || {};
const router = useRouter();
const {
data: postDetail,
isLoading: isFetchingPostDetail,
isError: isFetchPostDetailError,
} = useQueryPostDetail(postId as string);
const { mutate: updatePost, isLoading: isSubmitting } = useUpdatePost(
(data) => {
data?.id && router.push(`/posts/${data?.id}`);
toast.success("Post updated successfully");
},
() => {
toast.error("Something went wrong while updating post");
}
);
if (isFetchingPostDetail)
return (
<div className="my-24">
<Loading />
</div>
);
if (isFetchPostDetailError || !postDetail) return <ErrorMessage />;
return (
<div className="flex min-h-full flex-1 flex-col justify-center px-6 py-12 lg:px-8">
<div className="sm:mx-auto sm:w-full sm:max-w-7xl">
<h2 className="mt-10 text-center text-2xl font-bold leading-9 tracking-tight text-gray-900">
Update Post
</h2>
</div>
<div className="mt-10 sm:mx-auto sm:w-full sm:max-w-7xl px-8">
{/* Implement Post Form here */}
</div>
</div>
);
};
export default UpdatePost;
You might wonder what the [postId]
folder is for. This is how Nextjs helps us create a dynamic route. For instance, this dynamic route could be:
/update-post/1
/update-post/2
/update-post/3
and we can conveniently access the dynamic param postId
:
const { postId } = useParams() || {};
The postId is either 1, 2, or 3 depending on the url.
For our update post page, we also did some implementation. We used the postId
param to get the post detail with the useQueryPostDetail
hook, which we need as the default values for the post form that the user can update. We also used the useUpdatePost
hook to update the post when the user submits the form. For the UI, we managed the loading and error state. We also created the layout for this page with the title and the post form container.
The next thing we need to do is to implement the post form and call the mutate
function from the useUpdatePost
hook when the form is submitted. We already have the post form in the create post page, but we don’t want to copy it and create duplicate code. So we will refactor our create post page first and make the post form reusable. The first step is to move the form logic from the create post page to a new component called src/app/components/PostForm/index.tsx
"use client";
import * as yup from "yup";
import { useForm } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup";
import FormInput from "../form/FormInput";
import FormMarkDownInput from "../form/FormMarkDownInput";
import Button from "../atoms/Button";
import { useRouter } from "next/navigation";
import { CreatePostParams } from "@/app/hooks/useCreatePost";
export interface PostFormProps {
isLoading?: boolean;
defaultValues?: CreatePostParams;
buttonLabel?: string;
onSubmit: (values: CreatePostParams) => void;
}
const PostForm: React.FC<PostFormProps> = ({
isLoading,
defaultValues,
buttonLabel,
onSubmit,
}) => {
const router = useRouter();
const schema = yup.object().shape({
title: yup.string().nullable().required("Please input title"),
description: yup.string().nullable().required("Please input description"),
content: yup.string().nullable().required("Please input content"),
});
const {
control,
handleSubmit,
formState: { errors },
} = useForm<CreatePostParams>({
resolver: yupResolver(schema),
defaultValues,
});
return (
<form className="space-y-6" onSubmit={handleSubmit(onSubmit)}>
<FormInput
label="Title"
name="title"
containerClassName="max-w-md mx-auto"
control={control}
error={errors.title}
/>
<FormInput
containerClassName="max-w-md mx-auto"
label="Description"
name="description"
control={control}
error={errors.description}
/>
<FormMarkDownInput
label={"Content"}
control={control}
name="content"
error={errors.content}
/>
<div className="w-full flex items-center justify-center space-x-2">
<Button className="!w-32" variant="white" onClick={() => router.back()}>
Back
</Button>
<Button className="!w-32" type="submit" loading={isLoading}>
{buttonLabel}
</Button>
</div>
</form>
);
};
export default PostForm;
In addition to the form logic from the create post page, we include two parameters: defaultValues
and buttonLabel
. Their names indicate their functions. The defaultValues
parameter is for the update post page, so the user can see the existing post information before modifying it. The buttonLabel
parameter allows the button label to be dynamic.
After moving the logic, the create post page src/app/(authenticated-pages)/create-post/page.tsx
should look like this:
"use client";
import { useSession } from "next-auth/react";
import useCreatePost, { CreatePostParams } from "@/app/hooks/useCreatePost";
import { useRouter } from "next/navigation";
import { toast } from "react-toastify";
import PostForm from "@/app/components/PostForm";
const CreatePost: React.FC = () => {
const { data: session } = useSession();
const router = useRouter();
const { mutate: createPost, isLoading: isSubmitting } = useCreatePost(
(data) => {
toast.success("Post created successfully");
data?.id && router.push(`/posts/${data.id}`);
},
() => {
toast.error("Something went wrong while creating post");
}
);
const onSubmit = (values: CreatePostParams) =>
createPost({
...values,
accessToken: session?.accessToken,
});
return (
<div className="flex min-h-full flex-1 flex-col justify-center px-6 py-12 lg:px-8">
<div className="sm:mx-auto sm:w-full sm:max-w-7xl">
<h2 className="mt-10 text-center text-2xl font-bold leading-9 tracking-tight text-gray-900">
Create Post
</h2>
</div>
<div className="mt-10 sm:mx-auto sm:w-full sm:max-w-7xl px-8">
<PostForm
isLoading={isSubmitting}
onSubmit={onSubmit}
buttonLabel="Create Post"
/>
</div>
</div>
);
};
export default CreatePost;
We can now apply the PostForm to our src/app/(authenticated-pages)/update-post/page.tsx
"use client";
import PostForm from "@/app/components/PostForm";
import ErrorMessage from "@/app/components/atoms/ErrorMessage";
import Loading from "@/app/components/atoms/Loading";
import { CreatePostParams } from "@/app/hooks/useCreatePost";
import useQueryPostDetail from "@/app/hooks/useQueryPostDetail";
import useUpdatePost from "@/app/hooks/useUpdatePost";
import { useSession } from "next-auth/react";
import { useParams, useRouter } from "next/navigation";
import { toast } from "react-toastify";
const UpdatePost: React.FC = () => {
const { postId } = useParams() || {};
const { data: session } = useSession();
const router = useRouter();
const {
data: postDetail,
isLoading: isFetchingPostDetail,
isError: isFetchPostDetailError,
} = useQueryPostDetail(postId as string);
const { mutate: updatePost, isLoading: isSubmitting } = useUpdatePost(
(data) => {
data?.id && router.push(`/posts/${data?.id}`);
toast.success("Post updated successfully");
},
() => {
toast.error("Something went wrong while updating post");
}
);
const onSubmit = (values: CreatePostParams) =>
updatePost({
...values,
id: postId as string,
accessToken: session?.accessToken,
});
if (isFetchingPostDetail)
return (
<div className="my-24">
<Loading />
</div>
);
if (isFetchPostDetailError || !postDetail) return <ErrorMessage />;
return (
<div className="flex min-h-full flex-1 flex-col justify-center px-6 py-12 lg:px-8">
<div className="sm:mx-auto sm:w-full sm:max-w-7xl">
<h2 className="mt-10 text-center text-2xl font-bold leading-9 tracking-tight text-gray-900">
Update Post
</h2>
</div>
<div className="mt-10 sm:mx-auto sm:w-full sm:max-w-7xl px-8">
<PostForm
isLoading={isSubmitting}
defaultValues={{
title: postDetail?.title,
description: postDetail?.description,
content: postDetail?.content,
}}
onSubmit={onSubmit}
buttonLabel="Update Post"
/>
</div>
</div>
);
};
export default UpdatePost;
We used the PostForm
with defaultValues
set to the postDetail
that we obtained from useQueryPostDetail
. We also made the onSubmit
function that will add the access_token
before submitting the form data. This completes the update post page.
To update a post, we need to go from the my posts page. So we need to add the update button to the PostCard
. But the PostCard
is also used for the home page, where we don’t want the Update button to show. So we have to modify src/app/components/PostCard/index.tsx
"use client";
import Link from "next/link";
import Button from "../atoms/Button";
interface PostItemProps {
id: string;
title: string;
description: string;
createdAt?: Date;
onUpdate?: () => void;
onDelete?: () => void;
}
const PostCard: React.FC<PostItemProps> = ({
id,
title,
description,
createdAt,
onDelete,
onUpdate,
}) => {
const canEdit = onUpdate && onDelete;
return (
<div
key={id}
className="flex max-w-xl flex-col items-start justify-between"
>
<div className="flex items-center gap-x-4 text-xs">
{createdAt && (
<time dateTime={createdAt.toISOString()} className="text-gray-500">
{createdAt && createdAt.toLocaleDateString()}
</time>
)}
</div>
<div className="group relative">
<h3 className="mt-3 text-lg font-semibold leading-6 text-gray-900 group-hover:text-gray-600">
<Link href={`/posts/${id}`}>{title}</Link>
</h3>
<p className="mt-5 line-clamp-3 text-sm leading-6 text-gray-600">
{description}
</p>
</div>
{canEdit && (
<div className="mt-4 flex space-x-2 w-full">
<Button variant="white" className="!w-full flex-1" onClick={onUpdate}>
Edit
</Button>
<Button variant="red" className="!w-full flex-1" onClick={onDelete}>
Delete
</Button>
</div>
)}
</div>
);
};
export default PostCard;
The PostCard
also has a Delete
button, besides the Update
button. We included two functions onDelete
and onUpdate
. They are optional parameters, and we will show the Update and Delete buttons if they are given. Then we need to modify 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 useQueryMyPosts from "@/app/hooks/useQueryMyPostList";
import { useRouter } from "next/navigation";
const MyPosts: React.FC = () => {
const router = useRouter();
const { data: posts, isLoading, isError } = useQueryMyPosts();
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) => () => {
// implement later
};
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>
);
};
export default MyPosts;
We included two higher order functions onUpdate
and onDelete
. The onDelete
function will be implemented later. The onUpdate
function simply redirects the user to the update post page.
- You can now try out the update feature on our app at http://localhost:3000/my-posts:
Next, click the Edit
button to access the update page
You can update the post with these values:
- Title: Et netus et malesuada fames ac turpis. Euismod lacinia at quis risus sed
- Description: Egestas egestas fringilla phasellus faucibus scelerisque eleifend. Adipiscing diam donec adipiscing tristique. Et molestie ac feugiat sed lectus vestibulum mattis.
- Content:
Mauris pellentesque pulvinar pellentesque **habitant** morbi tristique senectus et netus
> Eget felis eget nunc lobortis mattis aliquam faucibus purus. Scelerisque varius morbi enim nunc.
Erat velit scelerisque in dictum. Tempor orci eu lobortis elementum nibh. Urna nec tincidunt praesent semper.
After that, click the Update Post
button, and the post will be updated successfully
Awesome! We finished the update post feature. The next part will be the delete post implementation.