본문 바로가기
✨FRONTEND/📍React

input 공통 컴포넌트로 만들기 (feat. react-hook-form + yup)

by 짱돌보리 2024. 8. 1.
728x90

✨ react-hook-form + yup으로 Input 공통 컴포넌트 만들기

폼을 만들다 보면 매번 이런 코드가 반복된다.

 

label, input, 에러 메시지, focus 스타일, 빨간 border 처리, onBlur 시 검증...

 

페이지마다 이걸 복붙하면 👉 유지보수 지옥 시작

그래서 이번에는 react-hook-form + yup 환경에 최적화된 Input 공통 컴포넌트를 만들어봤다

1️⃣ 타입에 따라 input 분기 처리

type?: 'text' | 'password'

 

password일 경우에만
👉 비밀번호 보이기 / 숨기기 토글 아이콘 추가

{type === 'text' ? (
  <input ... />
) : (
  <>
    <input type={isVisibility ? 'text' : 'password'} />
    <Icon onClick={togglePasswordVisibility} />
  </>
)}

💣register 타입 에러

처음엔 이렇게 쓰려고 했다.

register: UseFormRegister

 

근데 계속 타입 에러가 발생했다.

🔍 왜 그럴까?

UseFormRegister는 register 함수 자체의 타입이다.

 

하지만 우리가 Input 컴포넌트에 넘기는 건

register('email')

 

👉 즉, register 함수의 반환값

✅ 해결 방법

UseFormRegisterReturn 사용

import { UseFormRegisterReturn } from 'react-hook-form';

register: UseFormRegisterReturn;

 

UseFormRegisterReturn은 register() 호출 시 반환되는 객체 타입이다.

 

이 안에는 onChange, onBlur, ref, name 등이 모두 포함되어 있고, <input {...register} /> 이렇게 직접 input에 뿌려줄 수 있다.

 

👉 공통 input 컴포넌트에는 이 타입이 정답

2️⃣ input 크기가 다를 때?

페이지마다 max-width가 달랐다.

  • 로그인 → 640px
  • 모달 → 400px
  • 관리자 페이지 → full

기본값을 설정하고 필요할 때 프롭스로 내려주자.

 

✅ 기본값을 두고, 필요하면 props로 override

maxWidth?: string;

maxWidth = '640px'

<div style={{ maxWidth }}>

 

👉 공통 컴포넌트는 기본값을 가지고
👉 특수 케이스만 props로 조정

이게 가장 깔끔했다.

3️⃣ UX 개선: blur 시 진동 효과

검증 에러가 있을 때
사용자가 blur 하면 살짝 흔들리게 만들었다.

const [isVibrating, setIsVibrating] = useState(false);

const handleBlur = () => {
  if (onBlur) onBlur();
  if (!error) return;

  setIsVibrating(true);
  setTimeout(() => setIsVibrating(false), 300);
};
className={`
  ${isVibrating && 'animate-vibration'}
  ${error && 'border-red-400'}
`}

 

👉 단순히 빨간 border보다
👉 UX 피드백이 훨씬 명확해진다.

4️⃣ 책임 분리: PasswordInput 컴포넌트 분리

처음에는 Input 안에서 다 처리했다.

그런데 코드가 길어지기 시작했다.

  • visibility state
  • 아이콘 분기
  • blur 처리
  • 에러 처리

👉 그래서 비밀번호 전용 컴포넌트로 분리

{type === 'password' && (
  <PasswordInput
    name={name}
    register={register}
    error={error}
    onBlur={handleBlur}
  />
)}

5️⃣ Textarea도 같은 패턴으로 확장

type TextareaProps = TextareaHTMLAttributes<HTMLTextAreaElement> & {
  register: UseFormRegisterReturn;
  error?: FieldError;
};

✨최종 코드

최종 구조 ))

components/
 ├─ Input.tsx
 ├─ PasswordInput.tsx
 └─ Textarea.tsx

 

최종 코드 ))

  1. input 컴포넌트
import React, { useState } from 'react';
import { FieldError, UseFormRegisterReturn } from 'react-hook-form';

import PasswordInput from './PasswordInput';

type InputProps = {
  label: string;
  name: string;
  type?: 'text' | 'password' | 'number' | 'email';
  placeholder: string;
  register: UseFormRegisterReturn;
  error?: FieldError;
  maxWidth?: string;
  onBlur?: () => void;
};

export default function Input({
  label,
  name,
  type,
  placeholder,
  register,
  error,
  maxWidth = '640px',
  onBlur,
}: InputProps) {
  const [isVibrating, setIsVibrating] = useState(false);

  const handleBlur = () => {
    if (onBlur) onBlur();
    if (!error) return;
    setIsVibrating(true);
    setTimeout(() => setIsVibrating(false), 300);
  };

  return (
    <div className={`relative flex w-full flex-col gap-2`} style={{ maxWidth }}>
      <label htmlFor={name}>{label}</label>
      {(type === 'text' || type === 'number' || type === 'email') && (
        <input
          type={type}
          id={name}
          placeholder={placeholder}
          {...register}
          onBlur={handleBlur}
          className={`h-58 rounded-md border-2 px-16 outline-none focus:border-custom-green-200 ${isVibrating && 'animate-vibration'} ${error && 'border-red-400'}`}
        />
      )}
      {type === 'password' && (
        <PasswordInput
          name={name}
          placeholder={placeholder}
          register={register}
          error={error}
          onBlur={handleBlur}
        />
      )}
      {error && (
        <p className="pl-8 text-xs-regular text-custom-red-200">
          {error.message}
        </p>
      )}
    </div>
  );
}
  1. PasswordInput 컴포넌트
import { PasswordOffIcon, PasswordOnIcon } from '@/libs/utils/Icon';
import React, { useState } from 'react';
import { FieldError, UseFormRegisterReturn } from 'react-hook-form';

type PasswordInputProps = {
  name: string;
  placeholder: string;
  register: UseFormRegisterReturn;
  error?: FieldError;
  onBlur?: () => void;
};

export default function PasswordInput({
  name,
  placeholder,
  register,
  error,
  onBlur,
}: PasswordInputProps) {
  const [isVisibilityIcon, setIsVisibilityIcon] = useState(false);
  const [isVibrating, setIsVibrating] = useState(false);

  const togglePasswordVisibility = () => {
    setIsVisibilityIcon(!isVisibilityIcon);
  };

  const handleBlur = () => {
    if (onBlur) onBlur();
    if (!error) return;
    setIsVibrating(true);
    setTimeout(() => setIsVibrating(false), 300);
  };

  return (
    <>
      <input
        type={isVisibilityIcon ? 'text' : 'password'}
        id={name}
        placeholder={placeholder}
        {...register}
        onBlur={handleBlur}
        className={`h-58 rounded-md border-2 px-16 outline-none focus:border-custom-green-200 ${isVibrating && 'animate-vibration'} ${error && 'border-red-400'}`}
      />
      {isVisibilityIcon ? (
        <PasswordOnIcon
          width={24}
          height={24}
          className="absolute right-20 top-42 cursor-pointer"
          onClick={togglePasswordVisibility}
        />
      ) : (
        <PasswordOffIcon
          width={24}
          height={24}
          className="absolute right-20 top-42 cursor-pointer"
          onClick={togglePasswordVisibility}
        />
      )}
    </>
  );
}
  1. Textarea 컴포넌트
import React, { TextareaHTMLAttributes } from 'react';
import { FieldError, UseFormRegisterReturn } from 'react-hook-form';

type TextareaProps = TextareaHTMLAttributes<HTMLTextAreaElement> & {
  label: string;
  register: UseFormRegisterReturn;
  error?: FieldError;
  maxWidth?: string;
  height?: string;
  onBlur?: () => void;
};


export default function Textarea({
  label,
  register,
  error,
  maxWidth = '640px',
  height = '346px',
  onBlur,
  ...props
}: TextareaProps) {
  const { name, placeholder } = props;

  return (
    <div className="relative flex w-full flex-col gap-2" style={{ maxWidth }}>
      <label htmlFor={name}>{label}</label>
      <textarea
        id={name}
        placeholder={placeholder}
        {...register}
        onBlur={onBlur}
        className={`resize-none rounded-md border-2 p-16 outline-none focus:border-custom-green-200 ${error ? 'border-red-400' : ''}`}
        style={{ height }}
        {...props}
      />
      {error && (
        <p className="pl-8 text-xs-regular text-custom-red-200">
          {error.message}
        </p>
      )}
    </div>
  );
}

✨결과