Part 11 — Develop Update Post Page in Frontend

Loi Le
9 min readMar 2, 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 >

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.

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.

Index | < Pre | Next >

--

--