NextJS react-hook-form + 서버액션 폼 구현

JM·2024년 12월 18일
0

NextJS Page router로 3년 가까이 작업을 했다가 작년인 2023년부터 App router 안정화 버전을 출시하면서 공부를 시작했다.

하지만 2023년 출시 직후의 앱 라우터는 불완전하다고 생각이 들었다. 킬러피처라고 생각했던 서버액션 조차 Experimental 기능이었기 때문에 그 이후로 관심을 두지 않았다.

그러다가 최근에 간단한 프로젝트를 진행할 일이 있었는데 SEO가 상당히 중요하기도 서버 액션도 실험기능이 아닌 정식으로 지원한다고 해서 토이 프로젝트는 App router로 진행해보기로 했다.

사실 앱 라우터 설계 자체가 예전 PHP나 Ruby on Rails로 작업하는 것과 유사해서 결국 유행은 돌고 돈다는 말이 생각이 들었다.

작업하는데 크게 걸림돌은 없었지만 클라이언트 사이드 폼 Validation과 서버액션을 통합하는 부분에서 많은 삽질을 했다. 구글링을 해봐도 레퍼런스도 많이 없어서 더 고생한 것 같다.

그래서 나처럼 고생하고 있을 분들을 위해 React-hook-form을 이용한 클라이언트 사이드 폼 Validation과 서버액션을 통합한 간단한 코드 스니펫을 공유하고자 한다.

zod + react-hook-form 을 이용한 클라이언트 Validation을 진행하며, 같은 zod 스키마로 서버에서 한번 더 폼 데이터 Validation 을 진행한다.

schema.ts

import { z } from "zod";

export const postSchema = z.object({
  title: z
    .string()
    .min(3, "제목은 최소 3글자 이상 입력해주세요.")
    .max(100, "제목 글자 수가 너무 많습니다."),
  content: z.string(),
});

page.tsx

import NewPostForm from "./(components)/form";
import { createPost } from "../../../actions";

export default async function NewPost({
  params,
}: {
  params: Promise<{ category: string }>;
}) {
  const category = (await params).category;

  const action = createPost.bind(null, category); // 서버액션 함수를 bind 하여 카테고리를 서버액션 인자로 보냄.
// 참고 문서: https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations#passing-additional-arguments

  return (
    <div className="flex justify-center items-center min-h-screen bg-gray-50">
        <h1>새 게시글 작성</h1>

        <NewPostForm category={category} action={action} />
    </div>
  );
}

form.tsx
useForm 훅을 사용해야 하기 때문에 클라이언트 컴포넌트 (use client)인 점 유의

"use client";

import React, { useActionState, useEffect } from "react";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { FormState } from "../../../../actions";
import { postSchema } from "../schema";

type PostFormData = z.infer<typeof postSchema>;

const NewPostForm: React.FC<{
  category: string;
  action: (previousState: FormState, data: FormData) => Promise<FormState>;
}> = ({ action }) => {
  const [state, formAction] = useActionState(action, {
    field: "",
    message: "",
  });

  const { register, formState, setError } = useForm<PostFormData>({
    resolver: zodResolver(postSchema),
    defaultValues: { title: "", content: "" },
    mode: "all", // handleSubmit을 사용하지 않을 것이므로 all로 설정하여 Validation 진행
  });

  // Form 제출후 오류객체가 리턴되면 form에 에러 수동등록
  useEffect(() => {
    if (state.field && state.message) {
      setError(
        state.field as never,
        { message: state.message },
        { shouldFocus: true }
      );
    }
  }, [state, setError]);

  return (
    <form action={formAction}>
      {formState.errors.root && (
        <p aria-live="polite">{formState.errors.root.message}</p>
      )}

      <div>
        <label htmlFor="title">Title</label>
        <input
          id="title"
          type="text"
          placeholder="Enter the post title"
          {...register("title")}
        />
        {formState.errors.title && (
          <p>{formState.errors.title.message}</p>
        )}
      </div>

      <div>
        <label htmlFor="content">Content</label>
        <textarea
          id="content"
          placeholder="Write your content here..."
          rows={5}
          {...register("content")}
        ></textarea>
        {formState.errors.content && (
          <p>{formState.errors.content.message}</p>
        )}
      </div>

      <div>
        <button
          type="submit"
          disabled={formState.isSubmitting} // !formState.isValid 로 추가 검증가능 (다만 그럴경우 자바스크립트 Disable시 폼 제출이 불가능해서 사용안했음)
        >
          {formState.isSubmitting ? "Submitting..." : "Submit"}
        </button>
      </div>
    </form>
  );
};

export default NewPostForm;

actions.ts

"use server";

import { prisma } from "@/prisma";
import { redirect } from "next/navigation";
import { postSchema } from "./posts/[category]/new/schema";
import { auth } from "../auth";

export type FormState = { field: string; message: string };

export async function createPost(
  category: string,
  data: FormData
): Promise<FormState> {
  // 서버 사이드 폼 Validation
  const formData = Object.fromEntries(data);
  const parsed = postSchema.safeParse(formData);
  if (!parsed.success) {
    return {
      field: "root",
      message: "제목과 내용을 정상적으로 작성해주세요.",
    };
  }

  const title = parsed.data.title;
  const content = parsed.data.content;

  if (!title) {
    return { field: "title", message: "제목을 입력하세요." };
  } else if (!content) {
    return { field: "content", message: "내용을 입력하세요." };
  }

  // 비회원일 경우 로그인페이지로 redirect
  const session = await auth();
  if (!session?.user?.id) {
    redirect("/api/auth/signin");
  }

  // DB 저장
  const userId = session.user.id;
  const slug = await generateUniqueSlug(title);
  await prisma.post.create({
    data: {
      slug,
      category: { connect: { urlParameter: category } },
      title,
      content,
      user: { connect: { id: userId } },
    },
  });

  // 생성된 게시글로 리다이렉트
  redirect(`/posts/${category}/${encodeURIComponent(slug)}`);
}
profile
No one is perfect, but I'm still striving for perfection

0개의 댓글

관련 채용 정보