뚝딱햄 탈출기

React-hook-form 적용 및 추상화 삽질기: useForm을 공통 컴포넌트 내부에 선언하면 reset()이 작동하지 않는 이유 본문

web

React-hook-form 적용 및 추상화 삽질기: useForm을 공통 컴포넌트 내부에 선언하면 reset()이 작동하지 않는 이유

hyrmzz1 2024. 12. 3. 21:06

지난달에 제공된 백엔드 서버를 이용해 JWT를 활용한 로그인/회원가입 + CRUD를 구현하는 개인 프로젝트를 진행했다. 

React-hook-form을 적용한 과정과, useForm을 추상화하는 과정에서의 트러블 슈팅을 기록하려 한다.

React-hook-form이란?

폼 유효성 검사와 상태 관리를 처리할 수 있게 도와주는 라이브러리이다.

처음 사용해봤는데 왜 이제 사용해봤나 싶을 정도로 편리했고, 친절하게 작성된 공식 문서와 많은 레퍼런스 덕에 쉽게 익힐 수 있었다!

어디에 적용했냐면...

내 프로젝트에는 총 네 개의 폼이 쓰였다.

  1. 로그인
  2. 회원가입
  3. 글 생성
  4. 글 수정

각 폼에 react-hook-form을 적용한 방식과, 버튼만 다른 3) 글 생성 4) 글 수정 폼을 공통 컴포넌트화한 과정을 작성하겠다.

1) 로그인

UI를 먼저 구성했고, 공통적으로 사용되는 input과 button은 컴포넌트화했다.

AuthInput은 input의 type, name, placeholder를 prop으로 받도록 구현했고, ActionBtn은 button의 text를 prop으로 받도록 구현했으며 기본 type은 submit으로 지정했다.

// 로그인
import ActionBtn from "../components/ui/ActionBtn";
import AuthInput from "../components/ui/AuthInput";

const Login = () => {
  return (
    <>
      <form className="form-base my-6">
        <AuthInput type="email" name="email" placeholder="이메일" />
        <AuthInput type="text" name="password" placeholder="비밀번호" />
        <ActionBtn text="로그인" />
      </form>
      {/* 생략 */}
    </>
  );
};

export default Login;

React Hook Form 적용

여기에 유효성 검사 기능을 추가하기 위해 react-hook-form을 적용했다.

내 프로젝트 요구사항은 다음과 같다.

  • 이메일과 비밀번호의 유효성을 확인합니다.
    • 이메일 조건 : 최소 @, . 포함
    • 비밀번호 조건 : 8자 이상 입력
    • 이메일과 비밀번호가 모두 입력되어 있고, 조건을 만족해야 제출 버튼이 활성화 되도록 해주세요.

추가적으로 제출 이벤트 처리 중에는 제출 버튼을 비활성화해 중복 제출을 방지했고,
각 입력 필드에 오류가 발생하면 하단에 오류 메세지가 표시되고, 스타일이 변경되도록 구현했다.

useForm

useForm은 폼 상태 관리와 유효성 검사를 간결하게 처리하는 훅이다.

나는 email, password로 구성된 UserInput이라는 type을 지정해주었다.

const {
  register,
  handleSubmit,
  formState: { isSubmitting, isSubmitted, errors },
} = useForm<UserInput>();
  • register: 입력 필드를 폼에 등록하고 유효성 검사를 설정한다.
  • handleSubmit: 폼 데이터를 수집하고 유효성 검사를 통과한 경우에만 지정한 콜백을 실행한다.
  • formState: 폼의 상태를 추적하며 아래와 같은 정보들을 제공한다.
    • isSubmitting: 폼 제출 중
    • isSubmitted: 폼 제출 완료
    • errors: 각 입력 필드에서 발생한 유효성 검사 에러 정보 포함

폼 제출 처리

`handleSubmit`은 폼 데이터를 수집하고 유효성 검사를 수행한 뒤, 조건을 만족한 경우에만 onSubmit 콜백을 실행한다.

<form onSubmit={handleSubmit(onSubmit)} className="form-base my-6">
    // 생략
    <ActionBtn text="로그인" disabled={isSubmitting} />
</form>

ActionBtn의 기본 타입을 submit으로 설정해두었기 때문에 클릭 시 폼의 onSubmit 이벤트를 트리거한다.

또한 `isSubmitting`를 활용해 폼 제출이 처리 중일 때 버튼이 비활성화되도록 구현했다.

입력 필드에 유효성 검사 추가

<AuthInput
  type="email"
  placeholder="이메일"
  aria-invalid={
    isSubmitted ? (errors.email ? "true" : "false") : undefined
  }
  isError={!!errors.email}
  errorMessage={errors.email?.message?.toString() || ""}
  {...register("email", {
    required: "이메일을 입력해주세요", // `required: { value: true, message: "이메일을 입력해주세요" },` 와 같다.
    pattern: {
      value: /^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,4}$/i,
      message: "이메일 형식에 맞지 않습니다.",
    },
  })}
/>

<AuthInput
  type="password"
  placeholder="비밀번호"
  aria-invalid={
    isSubmitted ? (errors.password ? "true" : "false") : undefined
  }
  isError={!!errors.password}
  errorMessage={errors.password?.message?.toString() || ""}
  {...register("password", {
    required: "비밀번호를 입력해주세요",
    minLength: { value: 8, message: "비밀번호는 8자 이상입니다." },
  })}
/>
  • register("<필드명>"): 해당 필드를 폼에 등록하고 유효성 조건을 지정한다.
    • register("email")이라고 적으면 name 속성이 자동으로 "email"으로 설정된다.
    • 유효성 검사 조건
      • required: 필수 입력 항목으로 설정해 값이 비어 있는 경우 에러 메시지를 표시한다.
      • pattern: 정규식을 사용해 입력 값의 형식을 검사한다.
      • minLength: 최소 길이를 검사한다.
      • 이 외에도 많은 속성이 있다. (공식 문서 참고)
  • 에러 처리
    • errors.<필드명>?.message: 에러 메세지를 조건에 따라 표시한다.
    • isError={!!errors.<필드명>}: 에러 여부에 따라 스타일을 변경한다.
  • 접근성 속성
    • aria-invalid: 유효성 검사 실패 시 스크린 리더 사용자에게 오류를 알린다.
    • role="alert":에러 메시지가 사용자에게 즉각적으로 전달된다.
import { forwardRef } from "react";

interface AuthInputProps {
  type?: string; // 초기값(text) 지정했으므로 optional
  placeholder: string;
  isError?: boolean;
  errorMessage?: string;
  // name은 react-hook-form의 register에서 제공
}

const AuthInput = forwardRef<HTMLInputElement, AuthInputProps>((props, ref) => {
  const { type = "text", placeholder, isError, errorMessage, ...rest } = props;

  return (
    <>
      <input
        type={type}
        placeholder={placeholder}
        ref={ref}
        autoComplete="off"
        className={`input-base ${isError ? "input-error" : "input-normal"}`}
        {...rest}
      ></input>

      {isError && errorMessage && (
        <p role="alert" className="text-text_error text-sm">
          {errorMessage}
        </p>
      )}
    </>
  );
});

export default AuthInput;

AuthInput에는 원래 type, placeholder, name만 있었다.

name은 register가 제공하므로 제외했고, isError와 errorMessage prop을 추가했다.

  • forwardRef ⭐️⭐️⭐️
    • React Hook Form의 register 함수는 ref를 사용해 입력 필드와 폼 상태를 연결한다.
      하지만 AuthInput와 같은 커스텀 컴포넌트를 사용하면 내부의 <input>에 ref를 직접 전달할 수 없다. (커스텀 컴포넌트는 외부에서 전달된 ref를 내부 DOM 요소에 자동으로 전달하지 않기 때문)
    • 따라서 forwardRef를 사용해 외부에서 전달된 ref를 커스텀 컴포넌트 내부 DOM 요소인 <input>에 전달한다.
    • 이를 통해 React Hook Form의 register가 AuthInput 내부의 <input>과 연결된다.
  • isError: 해당 필드에 에러가 발생했는지 확인하고, 에러 상태에 따라 스타일을 변경한다.
  • errorMessage: 에러가 발생한 경우 사용자에게 에러 메시지를 표시한다.

2) 회원가입

회원가입 페이지는 '비밀번호 확인' 필드 외에는 로그인 페이지와 동일하다.

watch로 비밀번호 값 추적

watch()를 통해 특정 필드의 현재 값을 실시간으로 추적할 수 있다.

이를 활용해 비밀번호 확인 필드에서 입력된 값이 비밀번호 필드의 값과 일치하는지 확인했다.

const currPassword = watch("password");

입력 필드에 유효성 검사 추가

<AuthInput
  type="password"
  placeholder="비밀번호 확인"
  aria-invalid={
    isSubmitted
      ? errors.passwordConfirm
        ? "true"
        : "false"
      : undefined
  }
  isError={!!errors.passwordConfirm}
  errorMessage={errors.passwordConfirm?.message?.toString() || ""}
  {...register("passwordConfirm", {
    required: "비밀번호를 입력해주세요.",
    validate: (value) =>
      value === currPassword || "비밀번호가 일치하지 않습니다.",
  })}
/>
  • validate
    • 입력된 값(value)이 currPassword와 동일한지 확인하고, 조건을 만족하지 않으면 "비밀번호가 일치하지 않습니다."라는 메시지를 표시한다.

비밀번호 확인 필드에서 에러 발생!

하지만 `watch()`만 사용할 경우 문제가 있었다.

아이디 / 비밀번호 / 비밀번호 확인 필드를 모두 입력한 후(비밀번호 확인 필드의 유효성 검사가 실행된 후에) 비밀번호 필드의 입력 값이 변경된다면 watch가 다시 변경된 값을 추적해 비밀번호 확인 필드의 유효성 검사가 실시될 것이라 생각했다.

 

그러나 비밀번호 필드의 값이 변경되더라도 비밀번호 확인 필드의 유효성 검사 결과는 업데이트되지 않았다.

watch()는 필드의 값을 추적하는 역할만 수행하며, 유효성 검사를 자동으로 다시 실행하지는 않기 때문이다.

 

이를 해결하기 위해 `useEffect`와 `trigger()`를 활용했다.

`trigger()`는 특정 필드의 유효성 검사를 강제로 실행할 수 있게 하는 함수다. 이를 `useEffect`와 함께 사용해 비밀번호 필드 값이 변경될 때 비밀번호 확인 필드의 유효성 검사가 다시 실행되도록 설정했다. 또한 `isSubmitted`를 통해 불필요한 검사를 방지했다.

const currPassword = watch("password");

useEffect(() => {
  if (isSubmitted) {
    trigger("passwordConfirm"); // passwordConfirm 필드의 유효성 검사 다시 실행
  }
}, [currPassword, trigger]);

전체 코드 (Login)

// 전체 코드
import { Link, useNavigate } from "react-router-dom";
import ActionBtn from "../components/ui/ActionBtn";
import AuthInput from "../components/ui/AuthInput";
import { useForm } from "react-hook-form";
import { UserInput } from "../types/user";
import { AuthResponse } from "../types/auth";
import instance from "../api/instance";
import { AxiosError } from "axios";

const Login = () => {
  const navigator = useNavigate();
  
  const onSubmit = async (data: UserInput) => {
    try {
      // 생략
    } catch (error) {
      // 생략
    }
  };

  const {
    register,
    handleSubmit,
    formState: { isSubmitting, isSubmitted, errors },
  } = useForm<UserInput>();

  return (
    <>
      <form onSubmit={handleSubmit(onSubmit)} className="form-base my-6">
        <AuthInput
          type="email"
          placeholder="이메일"
          aria-invalid={
            isSubmitted ? (errors.email ? "true" : "false") : undefined
          }
          isError={!!errors.email}
          errorMessage={errors.email?.message?.toString() || ""}
          {...register("email", {
            required: "이메일을 입력해주세요", // `required: { value: true, message: "이메일을 입력해주세요" },` 와 같다.
            pattern: {
              value: /^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,4}$/i,
              message: "이메일 형식에 맞지 않습니다.",
            },
          })}
        />
        <AuthInput
          type="password"
          placeholder="비밀번호"
          aria-invalid={
            isSubmitted ? (errors.password ? "true" : "false") : undefined
          }
          isError={!!errors.password}
          errorMessage={errors.password?.message?.toString() || ""}
          {...register("password", {
            required: "비밀번호를 입력해주세요",
            minLength: { value: 8, message: "비밀번호는 8자 이상입니다." },
          })}
        />
        <ActionBtn text="로그인" disabled={isSubmitting} />
      </form>
      {/* 생략 */}
    </>
  );
};

export default Login;

전체 코드 (Signup)

import { Link, useNavigate } from "react-router-dom";
import ActionBtn from "../components/ui/ActionBtn";
import AuthInput from "../components/ui/AuthInput";
import { useForm } from "react-hook-form";
import { useEffect } from "react";
import { UserInput } from "../types/user";
import { AuthResponse } from "../types/auth";
import instance from "../api/instance";
import { AxiosError } from "axios";

type SignupInput = UserInput & { passwordConfirm: string };

const Signup = () => {
  const navigate = useNavigate();

  const onSubmit = async (data: SignupInput) => {
    try {
      // 생략
    } catch (error) {
      // 생략
    }
  };

  const {
    register,
    handleSubmit,
    formState: { isSubmitting, isSubmitted, errors },
    watch,
    trigger,
  } = useForm<SignupInput>();
  
  const currPassword = watch("password");

  useEffect(() => {
    if (isSubmitted) {
      trigger("passwordConfirm"); // passwordConfirm 필드의 유효성 검사 다시 실행
    }
  }, [currPassword, trigger]);

  return (
    <>
      <form onSubmit={handleSubmit(onSubmit)} className="form-base my-6">
        <AuthInput
          type="email"
          placeholder="이메일"
          aria-invalid={
            isSubmitted ? (errors.email ? "true" : "false") : undefined
          }
          isError={!!errors.email}
          errorMessage={errors.email?.message?.toString() || ""}
          {...register("email", {
            required: "이메일을 입력해주세요.",
            pattern: {
              value: /^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,4}$/i,
              message: "이메일 형식에 맞지 않습니다.",
            },
          })}
        />
        <AuthInput
          type="password"
          placeholder="비밀번호"
          aria-invalid={
            isSubmitted ? (errors.password ? "true" : "false") : undefined
          }
          isError={!!errors.password}
          errorMessage={errors.password?.message?.toString() || ""}
          {...register("password", {
            required: "비밀번호를 입력해주세요.",
            minLength: { value: 8, message: "비밀번호는 8자 이상입니다." },
          })}
        />
        <AuthInput
          type="password"
          placeholder="비밀번호 확인"
          aria-invalid={
            isSubmitted
              ? errors.passwordConfirm
                ? "true"
                : "false"
              : undefined
          }
          isError={!!errors.passwordConfirm}
          errorMessage={errors.passwordConfirm?.message?.toString() || ""}
          {...register("passwordConfirm", {
            required: "비밀번호를 입력해주세요.",
            validate: (value) =>
              value === currPassword || "비밀번호가 일치하지 않습니다.",
          })}
        />
        <ActionBtn text="회원가입" disabled={isSubmitting} />
      </form>
      {/* 생략 */}
    </>
  );
};

export default Signup;

3) 글 생성, 4) 글 수정

글 생성(CreateTodoView)과 글 수정(TodoDetailsView)에 사용되는 폼은 UI와 동작 방식이 매우 유사하다.

따라서 이를 하나의 공통 컴포넌트(TodoForm)으로 추출해 재사용하고자 리팩토링을 진행했다.

기존 코드

폼 로직과 무관한 부분은 생략했다. (생략해도 너무 길어버렷...^^)

 

중복되는 부분은 다음과 같다.

  • 제목(input)과 내용(textarea) 입력 필드
  • 폼 제출 이벤트 처리 (handleSubmit)
  • 유효성 검사 (register)
  • 버튼 레이아웃 및 스타일
import { useForm } from "react-hook-form";
import { TodoInput } from "../../types/todos";
import useTodoStore from "../../stores/useTodoStore";
import useTodoAppStore from "../../stores/useTodoAppStore";
import ActionBtn from "./ActionBtn";

const CreateTodoView = () => {
  // 생략
  const onSubmit = async (data: TodoInput) => {
    try {
      await addTodo(data);
      reset(); // 폼 초기화
    } catch (error) {
      console.log("에러 발생:", error);
      alert("에러 발생! 다시 시도해주세요.");
    }
  };

  const {
    register,
    handleSubmit,
    formState: { isSubmitting, isSubmitted, errors },
    reset,
  } = useForm<TodoInput>();

  return (
    <div className="flex flex-col w-full h-full">
      <form
        onSubmit={handleSubmit(onSubmit)}
        className="form-base w-full h-full"
      >
        <input
          type="text"
          placeholder="제목을 입력해주세요."
          {...register("title", { required: true })}
          aria-invalid={isSubmitted ? (errors.title ? true : false) : undefined}
          className={`input-base ${
            errors.title ? "input-error" : "input-normal"
          }`}
        ></input>
        <textarea
          placeholder="내용을 입력해주세요."
          {...register("content", { required: true })}
          aria-invalid={
            isSubmitted ? (errors.content ? true : false) : undefined
          }
          className={`input-base textarea-base ${
            errors.content ? "input-error" : "input-normal"
          }`}
        ></textarea>
        <div className="flex-row-end">
          <ActionBtn
            type="button"
            onClick={() => setViewMode("list")}
            text="닫기"
          />
          <ActionBtn disabled={isSubmitting} text="추가하기" />
        </div>
      </form>
    </div>
  );
};
import { useEffect, useState } from "react";
import useTodoAppStore from "../../stores/useTodoAppStore";
import useTodoStore from "../../stores/useTodoStore";
import { TodoInput } from "../../types/todos";
import { useForm } from "react-hook-form";
import ActionBtn from "./ActionBtn";
import CancelIcon from "../../assets/cancel.svg?react";

interface TodoDetailsViewProps {
  todoId: string;
}

const TodoDetailsView = ({ todoId }: TodoDetailsViewProps) => {
  //생략
  const {
    register,
    handleSubmit,
    formState: { isSubmitting, isSubmitted, errors },
    reset,
  } = useForm<TodoInput>();

  const fetchTodo = async () => {
    // 생략
  };

  const handleUpdate = async (data: TodoInput) => {
    // 생략
  };

  const handleDelete = async () => {
    // 생략
  };

  // 수정 모드 초기값 설정
  useEffect(() => {
    if (isEditing && selectedTodo) {
      reset({
        title: selectedTodo.title,
        content: selectedTodo.content,
      });
    }
  }, [isEditing, selectedTodo, reset]);

  // 생략

  return (
    <div className="flex flex-col items-end w-full h-full">
      {/* 생략 */}
      {isEditing ? (
        <form
          onSubmit={handleSubmit(handleUpdate)}
          className="form-base mt-4 w-full h-full"
        >
          <input
            type="text"
            placeholder="제목을 입력해주세요."
            {...register("title", { required: true })}
            aria-invalid={
              isSubmitted ? (errors.title ? true : false) : undefined
            }
            className={`input-base ${
              errors.title ? "input-error" : "input-normal"
            }`}
          ></input>
          <textarea
            placeholder="내용을 입력해주세요."
            {...register("content", { required: true })}
            aria-invalid={
              isSubmitted ? (errors.content ? true : false) : undefined
            }
            className={`input-base textarea-base ${
              errors.content ? "input-error" : "input-normal"
            }`}
          ></textarea>
          <div className="flex-row-end">
            <ActionBtn
              type="button"
              onClick={() => {
                setIsEditing(false);
              }}
              disabled={isSubmitting}
              text="취소"
            />
            <ActionBtn disabled={isSubmitting} text="수정 완료" />
          </div>
        </form>
      ) : (
        {/* 생략 */}
      )}
    </div>
  );
};

❌ 공통 컴포넌트 설계 - 1. 내부에서 React Hook Form 사용하는 방식

react-hook-form(useForm) 관련 로직을 공통 컴포넌트 내부와 외부 중 어디에 작성할지 고민이 되었는데,

중복 코드를 제거하고자 공통 컴포넌트를 설계하는 것이므로 내부에서 관리하는 방식으로 구현했다.

const TodoForm = ({ onSubmit, isEditing, onCancel }) => {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting, isSubmitted },
    reset,
  } = useForm<TodoInput>();

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input
        {...register("title", { required: "제목을 입력해주세요." })}
        placeholder="제목을 입력해주세요."
      />
      <textarea
        {...register("content", { required: "내용을 입력해주세요." })}
        placeholder="내용을 입력해주세요."
      />
      <div>
        <button type="button" onClick={onCancel}>
          {isEditing ? "취소" : "닫기"}
        </button>
        <button type="submit" disabled={isSubmitting}>
          {isEditing ? "수정 완료" : "추가하기"}
        </button>
      </div>
    </form>
  );
};

그러나 위와 같이 구현하니 CreateTodoView, TodoDetailsView에서 `reset()`이 작동하지 않았다.

  • CreateTodoView
    • 기존에는 글을 생성한 후 `reset()`을 호출해 폼을 초기화했다
    • 그러나 폼 데이터를 제출한 후에도 입력값이 초기화되지 않고 기존 입력 값이 남아 있는 오류가 발생헀다.
  • TodoDetailsView
    • 기존에는 수정 모드에서 기존 데이터를 폼에 채우기 위해 `reset()`을 사용했다. (아래 코드 참고)
    • 그러나 초기값이 반영되지 않고 입력 필드가 비어있는 오류가 발생했다.
    • 또한 다른 글을 클릭해 (selectedTodo 변경) 수정 모드에 진입해도 폼 상태가 업데이트되지 않는 오류가 발생했다.
useEffect(() => {
    if (isEditing && selectedTodo) {
      reset({
        title: selectedTodo.title,
        content: selectedTodo.content,
      });
    }
  }, [isEditing, selectedTodo, reset]);

 

💡 오류 원인 1. `useForm`의 상태와 호출부(CreateTodoView, TodoDetailsView)의 상태는 독립적으로 동작한다.

  • `useForm`은 호출된 컴포넌트 내부에서만 상태를 관리하는 독립적인 훅이다.
  • 공통 컴포넌트 내부에서 `useForm`을 호출하면 호출부와 상태가 공유하지 못한다.
  • 따라서 호출부에서 새로운 상태를 전달하거나 폼 초기화를 시도해도, 공용 컴포넌트 내부의 폼 상태는 이를 반영하지 못했던 것이다.
    • 이로 인해 글 생성 후 폼이 초기화되지 않고 이전 입력 값이 그대로 남아있는 문제가 발생한 것이다 !!!!!!!

💡 오류 원인 2. `reset()`은 공용 컴포넌트 내부에서만 상태를 관리한다.

  • `reset()`은 호출 시점에 폼 데이터를 특정 값으로 초기화하며, 이후 상태 변경은 자동으로 반영되지 않는다. "단발성 동작"
  • `useForm` 내부에서 관리하는 상태와 호출부의 상태가 독립적으로 동작하기 때문에 호출부에서 새로운 데이터를 전달해도 그 변경 사항은 폼에 반영되지 않는다.
    • 이로 인해 글 수정 모드로 전환했을 때 호출된 순간에만 폼 데이터를 초기화하기 때문에, 이후 다른 글을 클릭해 selectedTodo가 변경되더라도 폼 상태가 업데이트되지 않고 이전 글의 데이터를 유지하는 문제가 발생했던 것이다 !!!!!! (reset이 한 번만 호출되고, 이후 selectedTodo가 변경되도 상태 갱신 X)
    • 글 수정 모드로 전환했을 때 폼이 비어 있던 문제는 아마 비동기가 원인인 것 같다. 위의 useEffect의 조건문을 더 세밀하게(`if (isEditing && selectedTodo.title && selectedTodo.content` 이런 식으로?) 작성하면 해결되었을 것 같다. (비동기 로직을 추가해보진 않았으나 아래 방식으로 공통 컴포넌트를 재설계하니 문제가 발생하지 않았다.)

✅ 공통 컴포넌트 설계 - 2. useForm 인스턴스를 호출부에서 관리

따라서 `useForm`을 호출부에서 관리하고, TodoForm은 필요한 상태와 메서드를 props로 전달하는 방식으로 재설계했다.

 

호출부에서 `useForm`을 관리하니 `reset()`을 호출부에서 제어할 수 있게 되었고, 상태 동기화 문제가 해결됐다!

결과적으로 `reset()`이 의도대로 작동해 폼 초기화 및 초기값 설정이 정상적으로 이루어졌다.

interface TodoFormProps {
  register: UseFormRegister<TodoInput>;
  handleSubmit: UseFormHandleSubmit<TodoInput>;
  onSubmit: (data: TodoInput) => void;
  formState: FormState<TodoInput>;
  onCancel: () => void;
  isEditing?: boolean;
}

const TodoForm = ({
  register,
  handleSubmit,
  onSubmit,
  formState,
  onCancel,
  isEditing = false,
}: TodoFormProps) => {
  const { errors, isSubmitting, isSubmitted } = formState;

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input
        {...register("title", { required: true })}
        placeholder="제목을 입력해주세요."
        className={`input-base ${errors.title ? "input-error" : ""}`}
      />
      <textarea
        {...register("content", { required: true })}
        placeholder="내용을 입력해주세요."
        className={`textarea-base ${errors.content ? "input-error" : ""}`}
      />
      <div>
        <button type="button" onClick={onCancel} disabled={isSubmitting}>
          {isEditing ? "취소" : "닫기"}
        </button>
        <button type="submit" disabled={isSubmitting}>
          {isEditing ? "수정 완료" : "추가하기"}
        </button>
      </div>
    </form>
  );
};
// CreateTodoView (글 생성)
const { register, handleSubmit, reset, formState } = useForm<TodoInput>();

const onSubmit = async (data: TodoInput) => {
  await addTodo(data);
  reset(); // 폼 초기화
};

return (
  <TodoForm
    register={register}
    handleSubmit={handleSubmit}
    onSubmit={onSubmit}
    formState={formState}
    onCancel={() => setViewMode("list")}
  />
);
// TodoDetailsView (글 수정)
const { register, handleSubmit, reset, formState } = useForm<TodoInput>();

useEffect(() => {
  if (isEditing && selectedTodo) {
    reset({
      title: selectedTodo.title,
      content: selectedTodo.content,
    });
  }
}, [isEditing, selectedTodo, reset]);

return (
  <TodoForm
    register={register}
    handleSubmit={handleSubmit}
    onSubmit={handleUpdate}
    formState={formState}
    onCancel={() => setIsEditing(false)}
    isEditing={true}
  />
);

 

Comments