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 the last part, we focused on the queries. In this part, we will deal with mutations. Mutations are usually used to modify data or cause server side-effects. React-query will assist us in doing this. Let’s start the journey.
First, please create a hook to call to our api to create new post src/app/hooks/useCreatePost.ts
import { useMutation } from "react-query";
import axios from "../axios";
import { Post, PostDto } from "../models/posts/types";
import { toModel } from "../models/posts";
export type CreatePostParams = Pick<
Post,
"title" | "description" | "content"
>;
const onMutate = async (params: CreatePostParams) => {
const { title, description, content } = params;
const response = await axios.post<{
data: PostDto;
}>(
`/api/posts`,
{
data: {
title,
description,
content,
},
},
);
return toModel(response.data?.data);
};
const useCreatePost = (
onSuccess?: (
data: Post | null | undefined,
variables: CreatePostParams,
context: any
) => void,
onError?: (error: any, variables: CreatePostParams, context: any) => void
) => {
return useMutation(onMutate, {
onSuccess,
onError,
});
};
export default useCreatePost;
the Pick
keyword help us to select needed fields that we will submit to create a new post. The rest is straightforward, we used axios
to communicate with server api and use react-query
to handle this mutation action. With the hook useMutation
, we have access to utilities that help us deal with the server mutation state, such as: mutate
function, loading or error state. We will explore them soon.
Next we plan to build the create new post page.
In the create post page, we plan to build a form with these controls such as: the input for post name and description, the markdown input for the post content and the submit button. They are generic controls and can be reuse, so we will create the atomic components for them.
Before of that, please add new package classnames
to help to combine classes in React easier
$ yarn add classnames
Then, please create src/app/components/atoms/Input/index.tsx
import classNames from "classnames";
import { ChangeEvent } from "react";
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement | HTMLTextAreaElement> {
labelClassNames?: string;
label?: string | React.ReactNode;
disabled?: boolean;
inputSize?: "default" | "sm";
placeHolder?: string;
isError?: boolean;
errorMessage?: string;
containerClassName?: string;
isMultipleLine?: boolean;
onChangeText?: (value: string) => void;
}
const borderStyles = {
default:
"focus:outline-none focus:ring-gray-400 focus:border-gray-400 focus:border-2 border-gray-300",
error:
"border-red-300 text-red-900 focus:outline-none focus:ring-red-500 focus:border-red-500",
};
const Input: React.FunctionComponent<InputProps> = ({
label,
labelClassNames,
value,
onChangeText,
disabled = false,
placeHolder,
isError = false,
errorMessage,
containerClassName,
...rest
}) => {
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
onChangeText && onChangeText(value);
};
return (
<div className={containerClassName}>
{label && (
<label
className={classNames(
"mb-1 block text-sm font-medium text-gray-700",
labelClassNames
)}
>
{label}
</label>
)}
<div>
<input
disabled={disabled}
value={value}
className={classNames(
"px-3 py-2 shadow-sm block w-full sm:text-sm rounded-md border placeholder-gray-400",
{
[borderStyles.error]: isError,
[borderStyles.default]: !isError,
}
)}
onChange={handleChange}
placeholder={placeHolder}
{...rest}
/>
</div>
{isError && errorMessage && (
<p className="mt-2 text-xs text-red-600">{errorMessage}</p>
)}
</div>
);
};
export default Input;
We handle multiple of state for this input such as: error, disable, loading. Base on each state, the style is changed corresponding. We also support to display label or error message.
Next, please create src/app/components/atoms/MarkDownInput/index.tsx
import classNames from 'classnames'
import dynamic from 'next/dynamic'
const MDEditor = dynamic(() => import('@uiw/react-md-editor'), { ssr: false })
export interface MarkDownInputProps {
label?: string
value?: string
isError?: boolean
errorMessage?: string
onChanged?: (value?: string) => void
}
const MarkDownInput: React.FunctionComponent<MarkDownInputProps> = ({
label,
value,
isError = false,
errorMessage,
onChanged,
}) => {
return (
<div className='w-full'>
{label && (
<label className="mb-1 block text-sm font-medium text-gray-700">
{label}
</label>
)}
<div
className={classNames(
'border rounded',
isError ? 'border-red-300' : 'broder-gray-100',
)}
>
<MDEditor
fullscreen={false}
value={value}
onChange={(value) => onChanged && onChanged(value)}
/>
</div>
{isError && errorMessage && (
<p className="mt-2 text-xs text-red-600">{errorMessage}</p>
)}
</div>
)
}
export default MarkDownInput
we wrapped@uiw/react-md-editor
to display label and error message information. we also handle the error state with the red border for the editor.
Next, please create src/app/atoms/Button/index.tsx
import classNames from "classnames";
const sizeClasses = {
default: "px-4 py-2 text-sm font-medium rounded-md",
sm: "px-2.5 py-2 text-xs font-medium rounded",
};
const iconClasses = {
sm: "h-3 w-3",
default: "h-4 w-4",
};
const variantClasses = {
primary:
"border-transparent text-white bg-black bg-opacity-90 hover:bg-opacity-70 focus:ring-black",
white:
"border-gray-300 text-gray-700 bg-white hover:bg-gray-100 focus:ring-transparent",
red: "border-red-300 text-red-700 bg-white hover:bg-red-100 focus:ring-transparent",
redSolid:
"border-transparent text-white bg-red-700 bg-opacity-90 hover:bg-opacity-70 focus:ring-red-500",
};
const loadingClasses = {
primary: "text-white",
white: "text-gray-500",
red: "text-white",
redSolid: "text-white",
};
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
size?: "default" | "sm";
variant?: "primary" | "white" | "red" | "redSolid";
loading?: boolean;
disable?: boolean;
className?: string;
}
const Button: React.FunctionComponent<ButtonProps> = ({
children,
size = "default",
variant = "primary",
loading = false,
disabled = false,
type = "button",
className,
...rest
}) => {
return (
<button
type={type}
className={classNames(
"inline-flex justify-center items-center border shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2",
sizeClasses[size],
variantClasses[variant],
{
"!bg-gray-300 !text-white": disabled,
},
className
)}
disabled={disabled || loading}
{...rest}
>
{loading ? (
<div className="py-0.5">
<svg
className={classNames(
"animate-spin",
iconClasses[size],
loadingClasses[variant]
)}
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx={12}
cy={12}
r={10}
stroke="currentColor"
strokeWidth={4}
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</div>
) : (
children
)}
</button>
);
};
export default Button;
We provided multiple options to user to customize the Button such as: size, variant, type or custom styles.
Now, we have enough controls to build the create post page. However, we plan to build a form so that we have to manage these state of the control inside the form such as: error, error message and these event for example onChange
, onBlur
, …
There is a famous tool to work with form in React: react-hook-form
. Lets install it
yarn add react-hook-form @hookform/resolvers
Next, please create src/app/components/form/i/index.tsx
import { Control } from "react-hook-form";
import React from "react";
import { Controller } from "react-hook-form";
import Input, { InputProps } from "../../atoms/Input";
interface FormInputProps extends InputProps {
control?: Control<any>;
name: string;
defaultValue?: string;
error?: any;
controllerProps?: any;
}
const FormInput = ({
controllerProps,
control,
name,
defaultValue,
error,
...rest
}: FormInputProps) => {
return (
<Controller
control={control}
render={({ field: { onChange, onBlur, value } }) => (
<Input
{...rest}
value={value as string}
onBlur={onBlur}
onChangeText={onChange}
isError={!!error}
errorMessage={error?.message}
/>
)}
name={name}
defaultValue={defaultValue}
{...controllerProps}
/>
);
};
export default FormInput;
and src/app/components/form/FormMarkDownInput/index.tsx
import React from 'react'
import { Control, Controller } from 'react-hook-form'
import MarkDownInput, { MarkDownInputProps } from '../../atoms/MarkDownInput'
export interface FormMarkDownInputProps extends MarkDownInputProps {
control?: Control<any>
name: string
defaultValue?: string
error?: any
controllerProps?: any
}
const FormMarkDownInput = ({
controllerProps,
control,
name,
defaultValue,
error,
...rest
}: FormMarkDownInputProps) => {
return (
<Controller
control={control}
render={({ field: { onChange, value } }) => (
<MarkDownInput
{...rest}
value={value}
onChanged={onChange}
isError={!!error}
errorMessage={error?.message}
/>
)}
name={name}
defaultValue={defaultValue}
{...controllerProps}
/>
)
}
export default FormMarkDownInput
We wrapped our controls in the Controller
component of react-hook-form
. By this way we can use the render
function to manage error
state, error message as well as onChange
event.
Last but not least, when the create new post success or fail, we should show error message. we plan to use react-toastify
to help us. We also need hero icons to display the icons. Lets add these packages:
yarn add react-toastify @heroicons/react
Then, please create src/app/components/atoms/Toast/index.tsx
"use client";
import { Slide, TypeOptions } from "react-toastify";
import { ToastContainer, toast } from "react-toastify";
import classNames from "classnames";
import {
CheckCircleIcon,
ExclamationCircleIcon,
InformationCircleIcon,
XCircleIcon,
} from "@heroicons/react/24/outline";
const contextStyles = {
success: "bg-green-50 text-green-800 border-green-600",
error: "bg-red-50 text-red-400 border-red-600",
info: "bg-blue-50 text-blue-400 border-blue-400",
warning: "bg-yellow-50 text-yellow-700 border-amber-600",
default: "bg-green-50 text-green-400 border-green-600",
};
const icons = {
success: <CheckCircleIcon />,
error: <XCircleIcon />,
info: <InformationCircleIcon />,
warning: <ExclamationCircleIcon />,
default: <InformationCircleIcon />,
};
const Toast = () => {
return (
<div className="absolute">
<ToastContainer
closeButton={false}
transition={Slide}
position={toast.POSITION.TOP_CENTER}
toastClassName={(context) =>
classNames(
"border rounded-md my-4 p-2 mx-6 md:mx-0 flex items-center justify-end text-sm",
contextStyles[context?.type || "default"]
)
}
icon={(iconParams) => {
const { type } = iconParams;
const seletedType: TypeOptions = type as TypeOptions;
return icons[seletedType || "default"];
}}
hideProgressBar={true}
/>
</div>
);
};
export default Toast;
we wrapped ToastContainer
to customize the styles as well as the Icon for each of types. we also specify some default options such as transition
, position
, …
Then, please update the src/app/globals.css
to apply the toast default styles:
@tailwind base;
@tailwind components;
@tailwind utilities;
@import url('react-toastify/dist/ReactToastify.css');
Then, please update src/app/layout.tsx
import Toast from "./components/atoms/Toast";
import QueryContext from "./contexts/QueryContext";
import "./globals.css";
import { Inter } from "next/font/google";
const inter = Inter({ subsets: ["latin"] });
export const metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className={inter.className}>
<Toast />
<QueryContext>
<div className="min-h-full">
<header className="border-b border-gray-200 bg-white">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="flex h-16 justify-between">
<div className="flex items-center">
<div className=" text-lg md:text-3xl font-semibold">
Lani Blog
</div>
</div>
</div>
</div>
</header>
{children}
</div>
</QueryContext>
</body>
</html>
);
}
We add the Toast
component into our app layout so that we can use anywhere.
Now we have enough tools to build our page: create post. Before of that please install the package:
yarn add yup
Now please create src/app/create-post/page.tsx
"use client";
import useCreatePost, { CreatePostParams } from "@/app/hooks/useCreatePost";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { toast } from "react-toastify";
import * as yup from "yup";
import { yupResolver } from "@hookform/resolvers/yup";
import FormInput from "../components/form/FormInput";
import FormMarkDownInput from "../components/form/FormMarkDownInput";
import Button from "../components/atoms/Button";
const CreatePost: React.FC = () => {
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),
});
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,
});
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">
<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={isSubmitting}>
Create Post
</Button>
</div>
</form>
</div>
</div>
);
};
export default CreatePost;
Firstly, we used yup
to define validation schema that required the title
, description
and content
. Then, we used useForm
hook from react-hook-form
to register our schema and this will help us manage the form state. Next, we used our hook that we created above useCreatePost
that we provided onSuccess
and onError
function. If the create post is success, we will use toast
to show the successful message, then use router
from useRoute
hook to redirect to the post detail page. If the create post is fail, we will show the error message with toast. Next, we define the onSubmit
function that execute the createPost
function from the useCreatePost
As you can see, we mostly applied definition coding style so that the code will be easy to understand and maintain.
Next we build the ui for our form. Please focus the form
element
<form className="space-y-6" onSubmit={handleSubmit(onSubmit)}>
...
</form>
with the HOC (higher order function) handleSubmit
from useForm
hook, we can pass our onSubmit
function. By this way, react-hook-form
will help to validate and aggregate the values to our onSubmit
function.
inside the form element is the list of form controls such as FormInput
and FormMarkDownInput
.Each of them, we have to provide label, name. We also need to provide control
and error
from the useForm
hook so that react-hook-form
can help to control their state.
After that is two action buttons: Back and Submit buttons. For the back button. when user click to it, we use router to back to the previous page. For the submit button, we use the type submit, so when user click to it, the form submission will be triggered, we also change the state of this button to loading when submitting the request to server.
Now you can access http://localhost:3000/create-post to create your post.
You can leave the field empty and click Create Post button to test the validation.
You can input all information for example:
- Title: Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
- Description: Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
- Content:
Auctor eu augue ut lectus arcu **bibendum** at. Et leo duis ut diam quam. Condimentum lacinia quis vel eros donec ac odio tempor orci.
> Vestibulum mattis ullamcorper velit sed ullamcorper. Mi bibendum neque egestas congue quisque egestas diam in. Pretium fusce id velit ut. In metus vulputate eu scelerisque felis imperdiet proin fermentum leo.
Aliquam purus sit amet luctus venenatis lectus magna fringilla urna. Amet purus gravida quis blandit turpis cursus in hac habitasse. Quis auctor elit sed vulputate mi sit amet mauris.
Then click submit button, you can see the successful message and you will be redirected to the post detail for your created post.
The last thing we should do is provide the Create button in the home page, so that user can click to go to the create post page. so please update src/app/posts/PostList/index.tsx
:
"use client";
import { Post } from "@/app/models/posts/types";
import ErrorMessage from "../../components/atoms/ErrorMessage";
import Loading from "../../components/atoms/Loading";
import useQueryPostList from "../../hooks/useQueryPostList";
import EmptyMessage from "../EmptyMessage";
import Introduction from "../Introduction";
import PostCard from "../PostCard";
import Button from "@/app/components/atoms/Button";
import { useRouter } from "next/navigation";
interface PostListProps {
posts: Post[];
}
const PostList: React.FC<PostListProps> = ({ posts }) => {
const { data: postList, isLoading, isError } = useQueryPostList(posts);
const router = useRouter();
const onGoToCreatePage = () => {
router.push("/create-post");
};
if (isLoading)
return (
<div className="my-24">
<Loading />
</div>
);
if (isError || !postList) return <ErrorMessage />;
return (
<main>
<div className="bg-white py-24">
<div className="mx-auto max-w-7xl px-6 lg:px-8">
<div className="mx-auto lg:mx-0 w-full md:flex justify-between items-center">
<Introduction />
<div className="mt-8 md:mt-0 md:text-right">
<Button
className="px-6 py-3.5 text-lg font-semibold"
onClick={onGoToCreatePage}
>
Create
</Button>
</div>
</div>
<div className="mx-auto max-w-7xl border-t border-gray-200 pt-10 mt-10 sm:mt-16 sm:pt-16 lg:mx-0 lg:max-w-none">
{postList.length === 0 ? (
<EmptyMessage />
) : (
<div className="grid grid-cols-1 gap-x-8 gap-y-16 w-full lg:grid-cols-3">
{postList.map((post) => (
<PostCard key={post.id} {...post} />
))}
</div>
)}
</div>
</div>
</div>
</main>
);
};
export default PostList;
we added the onGoToCreatePage
function that use the router
to navigate to /create-post
and added a button to use this function in the onClick
event. Now user can go to create post page from the post list page so that can create any posts.
Well done! You have reached an important milestone for the front end application by completing the post creation page. Now we have the ability to create a post, view the list of posts or post detail. However, we face a problem that anyone can create a post now. This is not good because we cannot distinguish the post author and the author should be the only one who can update or delete their post. Therefore, we need to implement authentication for the app and allow only the logged in user to create or manage their posts. But we will save this for the next part. This is a good moment to review what we have accomplished for the front end side so far.
Wrapping Up Part 3, 4 & 5
You have achieved a lot in your Front end project. Let’s review what you have done:
- You initiated your project with Next.js and built the home page with hard-coded data.
- You used
react-query
to interact with the server API for fetching and mutating data for the Post List page, Post Detail page and create Post page. You also handled the loading and error states for each page usingreact-query
’s features. - You learned how to structure your code in a neat and logical way and refactored your code as you added new features.