bandal.dev

React Hook Form에서 Next.js Server Actions 사용하기

React Hook Form으로 Next.js 14에서 추가된 ServerActions와 Zod를 이용한 Input 검증 방법에 대해 작성하였습니다.

18
Next.js
React Hook Form
Server Actions
Zod
shadcn-ui

만약 React에서 Form을 사용할 때 리렌더링 문제에 대해 조사해 본 적이 있으시다면, React-Hook-Form에 대해 들어보셨을 것입니다.
React-Hook-Form은 React에서 Input 입력마다 상태가 변경되어 리렌더링 되는 문제를 Ref를 이용하여 해결한 라이브러리입니다.
또한 유연하게 Form 컴포넌트를 구성할 수 있어서, 다양한 Form 상태를 관리할 수 있습니다.

그리고 최근, Next.js 14에서 Server Actions가 안정화 되었다는 발표가 나오면서, React-Hook-Form에서 Server Actions를 사용해보고 싶어졌습니다.
이번 글에선 제가 Next.js 14에서 React-Hook-Form으로 progressive enhancement를 유지하며 Serer Actions를 어떻게 처리하였는지 공유하고자 합니다.
React-Hook-Form은 아직까지 Server Actions를 지원하지 않고 있지만, 향 후 지원 예정인 점을 참고해주세요. 이번 글에선 7.51.4 버전을 사용하였습니다.
그전에 Server Actions에 대해 간단히 설명드리겠습니다.

Server Actions란?

Server Actions은 서버에서 실행되는 비동기 함수입니다.
RSC(React-Server-Component)에서 비동기로 서버 자원을 가져오거나, form으로 양식 제출 및 데이터 변경을 처리할 수 있습니다.

Server Actions를 사용하면 다음의 이점을 가질 수 있습니다.

  • 서버에서 동작: API 엔드포인트를 만들 필요가 없습니다. 서버에서 직접 실행하여 데이터를 가져오거나, 추가/수정/삭제할 수 있습니다.
  • 비동기 실행: 비동기로 실행되는 함수입니다. Server / Client Component 모두 사용 가능하며 재사용성이 높습니다.
  • progressive enhancement(점진적 향상): JS를 불러오는 중이거나, 비활성화된 상태더라도 양식을 제출할 수 있습니다.
  • 캐싱 및 재검증 통합: 변경된 UI와 새로운 데이터를 한번의 왕복으로 가져올 수 있습니다.

환경 구성

아래 설명은 Next.js 14 버전을 사용하고 있다는 가정하에 작성되었습니다.
우선, React-Hook-Form과 검증을 위한 Zod를 설치합니다.

pnpm i react-hook-form zod @hookform/resolvers

Form 작성

Server Action 함수 작성

우선 Form submit 시 작업을 처리하기 위한 Server Action 함수를 간단하게 작성해보겠습니다.
글 후반부에서 Zod 검증과 Error 핸들링을 추가하겠습니다.

/src/app/action.ts

"use server";
 
import { z } from "zod";
 
const formSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
});
 
export type FormValues = z.infer<typeof formSchema>;
 
export async function submitForm(
  _prevState: any,
  formData: FormData
): Promise<ActionState | void> {
 
  console.log("server action", formData);
 
  return {
    status: "success",
    data: {
      email: formData.email,
      password: formData.password,
    },
  };
}

Next.js에서 ServerAction을 사용하기 위한 Action 함수는 위가 기본 형태입니다.
Action 함수는 이전 상태 저장을 위한 prevState와 formData만 인자로 받을 수 있으므로 Argument부분은 고정입니다.
위에서 만든 action은 Input 유효성 검사나 에러, 성공 시 핸들링하지 않습니다. 아래부터 각 기능들을 추가해보겠습니다.

Form Page

React-Hook-Form은 Context 기반이여서 클라이언트 컴포넌트에서만 동작합니다. 파일 상단에 use client 를 선언하여 사용합니다.
방금 작성한 Action함수를 사용하기 위해 useFormState를 사용하여 Form 상태를 관리합니다.
ServerActions를 사용하기 위해선 <form />action 속성에 formAction을 넣어주어야 합니다.

/src/app/client-side-form.tsx

"use client";
 
import { useFormState } from "react-dom";
import { useForm } from "react-hook-form";
 
import { FormValues, submitForm } from "@/app/actions";
 
export default function ClientSideForm() {
 
  const [state, formAction] = useFormState(submitForm, null);
  const form = useForm();
 
  return (
    <Form {...form}>
      <form
        action={formAction}
      >
        ...
      </form>
    </Form>
  );
}

Input Component

<input /> 컴포넌트를 만들어보겠습니다.
React-Hook-Form은 Ref를 통해 입력값을 관리하기 때문에, fowardRef를 사용하여 구현하여야만 합니다.

/src/components/ui/input.tsx

import * as React from "react";
 
import { cn } from "@/lib/utils";
 
export interface InputProps
  extends React.InputHTMLAttributes<HTMLInputElement> {}
 
const Input = React.forwardRef<HTMLInputElement, InputProps>(
  ({ type, ...props }, ref) => {
    return (
      <input
        type={type}
        ref={ref}
        {...props}
      />
    );
  }
);
Input.displayName = "Input";
 
export { Input };

이 후, 필드 영역에 Input 컴포넌트를 사용하여 Form을 구성하면 됩니다.

"use client";
 
import { useFormState } from "react-dom";
import { useForm } from "react-hook-form";
 
import { FormValues, submitForm } from "@/app/actions";
 
export default function ClientSideForm() {
 
  const [state, formAction] = useFormState(submitForm, null);
  const form = useForm();
 
  return (
    <Form {...form}>
      <form
        action={formAction}
      >
        <Input
          type="email"
          name="email"
          placeholder="Email"
          {...form.register("email")}
        />
        <Input
          type="password"
          name="password"
          placeholder="Password"
          {...form.register("password")}
        />
        <button type="submit">Submit</button>
      </form>
    </Form>
  );
}

React-Hook-Form의 Server-Actions

<form />의 action을 통해 submit을 하여도, React-Hook-Form의 상태는 변하지 않습니다.
이는 React-Hook-Form이 Context 기반으로 동작하기 때문입니다.
그래서 저는 Custom Hook을 만들어서 Form의 상태를 관리하였습니다.

상태 종류 정의

먼저, 상태 종류를 관리하기 위한 type 파일을 작성합니다.

아래 중 VALIDATION_ERROR는 Zod를 통해 검증된 데이터가 아닌 경우 발생하는 에러입니다.
나머지는 사용할 상태를 커스텀하여 작성합니다.

/src/types/action.d.ts

type ActionState = 
  | {
      code: "SUCCESS" // 성공
      message: string;
    }
  | {
      code: "VALIDATION_ERROR"; // Zod 유효성 검사 에러
      fieldErrors: {
        [field: string]: string[];
      };
    }
  | {
      code: "EXISTS_ERROR"; // 커스텀 에러
      key: string;
      message: string;
    }
  | {
      code: "INTERNAL_ERROR"; // 알 수 없는 오류
      err: any;
    };

Custom Hook 작성

다음, useForm훅을 대체할 Custom Hook을 작성합니다.
Action 함수에서 반환된 state를 인자로 받아서 내부에서 상태를 관리하고, 에러 핸들링을 처리합니다.
또한, 성공 시 Client 단에서 처리할 수 있는 onSuccess 함수를 인자로 받아서 처리합니다.

/src/hooks/useFormAction.ts

"use client";
 
import { useCallback, useEffect } from "react";
import { FieldValues, useForm, UseFormProps } from "react-hook-form";
 
import { toast } from "@/components/ui/use-toast";
 
type UseFormActionProps<TFieldValues extends FieldValues = FieldValues, TContext = any> = UseFormProps<TFieldValues, TContext> & {
  state: ActionState | unknown;
  onSuccess?: () => void;
}
 
export function useFormAction<TFieldValues extends FieldValues = FieldValues, TContext = any>({
  state,
  onSuccess,
  ...props
}: UseFormActionProps<TFieldValues, TContext>) {
  const form = useForm({
    ...props,
  });
 
  const handleSuccess = useCallback(() => {
    onSuccess?.();
  // eslint-disable-next-line
  }, []);
 
  useEffect(() => {
    if( !hasState(state) ) return;
    form.clearErrors();
 
    switch (state.code) {
    case "INTERNAL_ERROR": // 알 수 없는 오류 핸들링
      toast({
        title: "Something went wrong.",
        description: "Please try again later.",
        variant: "destructive",
        duration: 5000,
      });
      break;
    case "VALIDATION_ERROR": // Zod 유효성 검사 에러 시 form field에 에러 상태 추가
      const { fieldErrors } = state;
      Object.keys(fieldErrors).forEach((key) => {
        form.setError(key as any, { message: fieldErrors[key].flat().join(" ") });
      });
      break;
    case "EXISTS_ERROR": // Custom Error Handling
      form.setError(state.key as any, { message: state.message });
      break;
    case "SUCCESS": // 성공 시 처리
      toast({
        title: state.message,
        duration: 5000,
      });
      handleSuccess();
      form.reset();
      break;
    }
    
  }, [state, form, handleSuccess]);
	
  return {
    ...form,
  };
}
 
// ActionState 타입인지 확인을 위한 Type Guard
const hasState = (state: ActionState | unknown): state is ActionState => {
 
  if(!state || typeof state !== "object") return false;
 
  return "code" in state;
};

Server Action 작성

우선 아래 전체 코드입니다.

/src/app/actions.ts

"use server";
 
import { z } from "zod";
 
const formSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
});
 
// 이미 존재하는 유저 이메일
const EXISTS_USER = [
  "abc@abc.com",
];
 
export type FormValues = z.infer<typeof formSchema>;
 
export async function submitForm(
  _prevState: any,
  formData: FormData
): Promise<ActionState | void> {
 
  // 전달받은 FormData를 Zod로 검증
  // 커스텀 에러 핸들링을 위해 사용 safeParse 사용
  const input = formSchema.safeParse({
    email: formData.get("email"),
    password: formData.get("password"),
  });
 
  if (!input.success) {
    const { fieldErrors } = input.error.flatten();
    return {
      code: "VALIDATION_ERROR",
      fieldErrors,
    };
  }
 
  try {
    if( EXISTS_USER.includes(input.data.email) ) {
      return {
        code: "EXISTS_ERROR",
        key: "email",
        message: "User already exists with this email.",
      };
    }
 
    // object equality check
    return {
      code: "SUCCESS",
      message: "Form submitted successfully!",
    };
  }
  catch (error) {
    return {
      code: "INTERNAL_ERROR",
      err: error,
    };
  }
}

Form Zod 스키마 정의

우선 Form 내에서 사용할 스키마를 zod를 이용하여 정의해줍니다.
또한, React-Hook-Form에서 Type를 활용하여 사용하기 위해 FormValues를 정의하고 export 합니다.

import { z } from "zod";
 
const formSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
});
 
export type FormValues = z.infer<typeof formSchema>;

Zod를 활용한 유효성 검사

전달받은 FormData를 Zod로 검증합니다.
parse함수를 사용할 경우 에러 발생 시 throw를 하기 때문에, safeParse를 사용하여 에러 핸들링을 할 수 있습니다.
custom Hook에서 정의한대로, VALIDATION_ERROR 코드가 반환되면, Form field에 에러 상태를 추가합니다.

// 전달받은 FormData를 Zod로 검증
// 커스텀 에러 핸들링을 위해 사용 safeParse 사용
const input = formSchema.safeParse({
  email: formData.get("email"),
  password: formData.get("password"),
});
 
if (!input.success) {
  const { fieldErrors } = input.error.flatten();
  return {
    code: "VALIDATION_ERROR",
    fieldErrors,
  };
}

성공 or 실패 처리

성공 시, SUCCESS 코드와 함께 메시지를 반환합니다.
custom Hook에서 정의한대로, SUCCESS 코드가 반환되면, onSuccess 함수를 실행하고, Form을 초기화합니다.

try {
  if( EXISTS_USER.includes(input.data.email) ) {
    return {
      code: "EXISTS_ERROR",
      key: "email",
      message: "User already exists with this email.",
    };
  }
 
  // object equality check
  return {
    code: "SUCCESS",
    message: "Form submitted successfully!",
  };
}
catch (error) {
  return {
    code: "INTERNAL_ERROR",
    err: error,
  };
}

Form Component

Form을 유연하게 사용하고, Form 메시지를 표시하기 위한 Form 컴포넌트를 작성합니다.
아래는 shadcn-ui의 Form 컴포넌트를 사용하여 작성하였습니다.

/src/components/ui/form.tsx

import * as LabelPrimitive from "@radix-ui/react-label";
import { Slot } from "@radix-ui/react-slot";
import * as React from "react";
import {
  Controller,
  ControllerProps,
  FieldPath,
  FieldValues,
  FormProvider,
  useFormContext
} from "react-hook-form";
 
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
 
const Form = FormProvider;
 
type FormFieldContextValue<
  TFieldValues extends FieldValues = FieldValues,
  TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
> = {
  name: TName
}
 
const FormFieldContext = React.createContext<FormFieldContextValue>(
  {} as FormFieldContextValue
);
 
const FormField = <
  TFieldValues extends FieldValues = FieldValues,
  TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
>({
    ...props
  }: ControllerProps<TFieldValues, TName>) => {
  return (
    <FormFieldContext.Provider value={{ name: props.name }}>
      <Controller {...props} />
    </FormFieldContext.Provider>
  );
};
 
const useFormField = () => {
  const fieldContext = React.useContext(FormFieldContext);
  const itemContext = React.useContext(FormItemContext);
  const { getFieldState, formState } = useFormContext();
 
  const fieldState = getFieldState(fieldContext.name, formState);
 
  if (!fieldContext) {
    throw new Error("useFormField should be used within <FormField>");
  }
 
  const { id } = itemContext;
 
  return {
    id,
    name: fieldContext.name,
    formItemId: `${id}-form-item`,
    formDescriptionId: `${id}-form-item-description`,
    formMessageId: `${id}-form-item-message`,
    ...fieldState,
  };
};
 
type FormItemContextValue = {
  id: string
}
 
const FormItemContext = React.createContext<FormItemContextValue>(
  {} as FormItemContextValue
);
 
const FormItem = React.forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLDivElement> & { className?: string }
>(({ className, ...props }, ref) => {
  const id = React.useId();
 
  return (
    <FormItemContext.Provider value={{ id }}>
      <div ref={ref} className={cn("space-y-2", className)} {...props} />
    </FormItemContext.Provider>
  );
});
FormItem.displayName = "FormItem";
 
const FormLabel = React.forwardRef<
  React.ElementRef<typeof LabelPrimitive.Root>,
  React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & { className?: string }
>(({ className, ...props }, ref) => {
  const { error, formItemId } = useFormField();
 
  return (
    <Label
      ref={ref}
      className={cn(error && "text-destructive", className)}
      htmlFor={formItemId}
      {...props}
    />
  );
});
FormLabel.displayName = "FormLabel";
 
const FormControl = React.forwardRef<
  React.ElementRef<typeof Slot>,
  React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
  const { error, formItemId, formDescriptionId, formMessageId } = useFormField();
 
  return (
    <Slot
      ref={ref}
      id={formItemId}
      aria-describedby={
        !error
          ? `${formDescriptionId}`
          : `${formDescriptionId} ${formMessageId}`
      }
      aria-invalid={!!error}
      {...props}
    />
  );
});
FormControl.displayName = "FormControl";
 
const FormDescription = React.forwardRef<
  HTMLParagraphElement,
  React.HTMLAttributes<HTMLParagraphElement> & { className?: string }
>(({ className, ...props }, ref) => {
  const { formDescriptionId } = useFormField();
 
  return (
    <p
      ref={ref}
      id={formDescriptionId}
      className={cn("text-muted-foreground text-sm", className)}
      {...props}
    />
  );
});
FormDescription.displayName = "FormDescription";
 
const FormMessage = React.forwardRef<
  HTMLParagraphElement,
  React.HTMLAttributes<HTMLParagraphElement> & { className?: string }
>(({ className, children, ...props }, ref) => {
  const { error, formMessageId } = useFormField();
  const body = error ? String(error?.message) : children;
 
  if (!body) {
    return null;
  }
 
  return (
    <p
      ref={ref}
      id={formMessageId}
      className={cn("text-destructive text-sm font-medium", className)}
      {...props}
    >
      {body}
    </p>
  );
});
FormMessage.displayName = "FormMessage";
 
export {
  Form,
  FormControl,
  FormDescription,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
  useFormField
};

Form Page 수정

마지막으로, Form 컴포넌트를 수정하여 Custom Hook을 사용하도록 변경합니다.

"use client";
 
import { useFormState } from "react-dom";
import { useForm, Form } from "react-hook-form";
 
import { useFormAction } from "@/hooks/useFormAction";
 
export default function ClientSideForm() {
 
  const [state, formAction] = useFormState(submitForm, null);
  const form = useFormAction<FormValues>({
    state,
    defaultValues: {
      email: "",
      password: "",
    },
    onSuccess: () => {
      console.log("Form submitted successfully!");
    },
  });
 
  return (
    <Form {...form}>
      <form
        action={formAction}
      >
        <FormFields />
      </form>
    </Form>
  );
}

로딩 및 에러 상태 표시

action에서 반환 된 에러 처리 및 action 실행 중 로딩 상태를 표시해보겠습니다.

React-Query를 사용해보셨다면 isLoading 표시가 얼마나 간단한지 알 수 있을 것입니다.

const { isLoading, data } = useMutation(...)

위에서 isLoading만 확인하면 됩니다.
하지만, ServerActions`에선 위보단 약간 더 복잡합니다.

우선, Form의 Fields 영역을 다른 컴포넌트로 분리시켜야만 합니다.
그리고 useFormStatuspending 상태를 확인하여 로딩 상태를 표시할 수 있습니다.

function FormFields() {
  const form = useFormContext();
  const { pending } = useFormStatus();
 
  return (
    <div className="space-y-4">
      <FormField
        control={form.control}
        name="email"
        render={({ field }) => (
          <FormItem>
            <FormLabel className="flex items-center justify-between">
              Email
              <FormMessage />
            </FormLabel>
            <FormControl>
              <Input
                {...field}
                type="email"
                placeholder="abc@abc.com"
                disabled={pending}
              />
            </FormControl>
          </FormItem>
        )}
      />
      <FormField
        control={form.control}
        name="password"
        render={({ field }) => (
          <FormItem>
            <FormLabel className="flex items-center justify-between">
              Password
              <FormMessage />
            </FormLabel>
            <FormControl>
              <Input
                {...field}
                type="password"
                placeholder="********"
                disabled={pending}
              />
            </FormControl>
          </FormItem>
        )}
      />
      <div className="flex justify-end">
        <Button
          isLoading={pending}
        >
          Submit
        </Button>
      </div>
    </div>
  );
}

결과 확인

React-Hook-Form에서 Server Actions를 사용하기 위한 모든 과정을 마쳤습니다.
이제, 아래와 같은 결과를 확인할 수 있습니다.

또한, Zod는 Server Side에서만 사용했기 때문에, Client Side의 bundle size에 영향을 주지 않습니다.

240513-232910

240513-232803

위 작성된 소스 코드는 아래 Github 저장소에서 확인할 수 있습니다.
Github Repo: React-Hook-Form-Server-Actions

Bonus: Zod 에러 메시지 커스터마이징

Zod는 기본값으로 영문 에러 메시지를 반환합니다.
하지만, ZodError 객체를 통해 에러 메시지를 커스터마이징 할 수 있습니다.

/src/utils/zod.ts

import { z } from "zod";
 
export const zodErrorMap: z.ZodErrorMap = (issue, ctx) => {
  switch (issue.code) {
  case z.ZodIssueCode.invalid_type:
    if (issue.received === "undefined" || issue.received === "null")
      return { message: "필수 입력 항목입니다." };
    if (issue.expected === "number")
      return { message: "숫자만 입력 가능합니다." };
    break;
  case z.ZodIssueCode.too_small:
    return { message: `최소 ${issue.minimum}자 이상 입력해주세요.` };
  case z.ZodIssueCode.too_big:
    return { message: `최대 ${issue.maximum}자까지 입력 가능합니다.` };
  case z.ZodIssueCode.invalid_string:
    if (issue.validation === "email")
      return { message: "이메일 형식으로 입력해주세요." };
    break;
  default:
    return { message: ctx.defaultError };
  }
 
  return { message: ctx.defaultError };
};

이후, Zod 스키마에 errorMap을 추가하여 커스터마이징한 에러 메시지를 반환할 수 있습니다.

/src/app/actions.ts

import { z } from "zod";
import { zodErrorMap } from "@/utils/zod";
 
const formSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
});
 
z.setErrorMap(zodErrorMap);

결론

여기까지 읽어주신 분들께 감사드립니다.

위 글은 대부분 제가 작성한 소스코드를 기반으로 작성되었습니다.
여러 문장의 글보다 직접 위 글에 있는 Custom Hook을 사용해보면 더욱 이해가 빠르게 될 것입니다.

Custom Hook은 최대한 복잡한 사용을 요구하지 않도록 작성해보았습니다.
또한 React-Hook-Form의 에러 상태를 활용하며, Ref를 활용한 렌더링 최적화 및 progressive enhancement를 유지하도록 작성하였습니다.

아직까진 ServerActions가 좋은 DX를 제공하진 않습니다.
하지만, 올해 출시 예정인 React 19부터는 공식적으로 form action이 추가되므로, 앞으로 계속 개선될 것으로 기대합니다.

참조