[BookShop-FE] 회원가입 페이지 구현, React Hook Form

라우터 등록

라우터에 경로를 등록해야 합니다.

import Signup from "./pages/Signup";  

const router = createBrowserRouter([
  { path: "/", element: <Layout><Home /></Layout> },
  { path: "/books", element: <Layout><div>도서 목록</div></Layout> },
  { path: "/signup", element: <Layout><Signup /></Layout> }, 
])

/signup 경로로 접근하면 Layout 안에 Signup 컴포넌트가 렌더링됩니다.

컴포넌트 Props 확장 (Button, InputText)

Button — React.ButtonHTMLAttributes 확장

// 변경 전
interface Props {
  children: React.ReactNode;
  size: ButtonSize;
  scheme: ButtonScheme;
  disabled?: boolean;
  isLoading?: boolean;
}

// 변경 후
interface Props extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  // 기존 Props는 동일하되, HTML button의 모든 속성을 추가로 사용 가능
}

extends React.ButtonHTMLAttributes<HTMLButtonElement>를 추가하면 type="submit", onClick, onFocus 같은 HTML 기본 속성을 별도 선언 없이 그냥 쓸 수 있습니다. Signup 폼에서 <Button type="submit"> 처럼 쓸 수 있는 이유입니다.

InputText — inputType과 ...props 전달

interface Props extends React.InputHTMLAttributes<HTMLInputElement> { 
	//기존 input이 가지고 있는 속성들 그대로 사용가능
    //추가로
  placeholder?: string;
  inputType?: "text" | "email" | "password" | "number";  // 타입 제한
}

const InputText = React.forwardRef(
  ({ placeholder, inputType, onChange, ...props }: Props, ref) => {
    return (
      <InputTextStyle
        placeholder={placeholder}
        ref={ref}
        type={inputType}       // ← inputType을 type으로 연결
        onChange={onChange}
        {...props}             // ← 나머지 HTML 속성 전부 전달
      />
    );
  }
);

forwardRef를 쓰는 이유는 부모 컴포넌트에서 ref로 input DOM에 직접 접근할 수 있게 하기 위해서입니다. (e.g. 자동 포커스, 유효성 검사)

회원가입 페이지 (Signup.tsx)

상태 관리 — useState

const [email, setEmail] = useState("");
const [password, setPassword] = useState("");

email과 password를 각각 상태로 관리합니다. input이 바뀔 때마다 상태가 업데이트되는 제어 컴포넌트(Controlled Component) 패턴입니다.

폼 제출 — handleSubmit

const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
  event.preventDefault();  // 브라우저 기본 동작(페이지 새로고침) 차단
  // 여기서 API 호출 예정
};

event.preventDefault()가 없으면 폼 제출 시 페이지가 새로고침되어 상태가 초기화됩니다.

 
import styled from "styled-components";
import Title from "../components/common/Title";
import InputText from "../components/common/InputText";
import Button from "../components/common/Button";
import { Link } from "react-router-dom";
import { useState } from "react";

function Signup() {
    const [email, setEmail] = useState("");
    const [password, setPassword] = useState("");
    
    const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
        event.preventDefault(); //페이지 이동을 막아줌

    }
    return (
        <>
            <Title size="large">회원가입</Title>
            <SignupStyle>
                <form onSubmit={handleSubmit}>
                    <fieldset>
                        <InputText placeholder="이메일" inputType="email" value={email} onChange={(e) => setEmail(e.target.value)}/>
                    </fieldset>
                    <fieldset>
                        <InputText placeholder="비밀번호" inputType="password" value={password} onChange={(e) => setPassword(e.target.value)}/>
                    </fieldset>
                    <fieldset>
                        <Button type="submit" size="medium" scheme="primary">
                            회원가입
                        </Button>
                    </fieldset>
                    <div className="info">
                        <Link to="/reset">비밀번호 초기화</Link>
                    </div>
                </form>
            </SignupStyle>
        </>
    )
}

const SignupStyle = styled.div`
    max-width: ${({theme}) => theme.layout.width.small};
    margin: 80px auto;

    fieldset {
        border: 0;
        padding: 0 0 8px 0
        .error-text {
            color: red;
        }
    }
    
    input {
        width: 100%;
    }

    button {
        width: 100%;;
    }

    .info {
        text-align: center;
        padding: 16px 0 0 0;
    }
`;

export default Signup;

 

 

React Hook Form 사용해 onChange 간단화하기

현재의 방식은 onChange를 하나하나 만들고 있습니다. 

그런데 이렇게되면 onChange가 불필요하게 많아지고, 길어질 수 있습니다. 

사용자 입력
    ↓
onChange → setEmail / setPassword (상태 업데이트)
    ↓
버튼 클릭 → form의 onSubmit 발동
    ↓
handleSubmit → event.preventDefault() → API 호출 (예정)

 

 

이걸 방지하기 위해 React Hook Form 이라는 라이브러리를 사용해보고자합니다. 

https://react-hook-form.com/

 

React Hook Form - performant, flexible and extensible form library

Performant, flexible and extensible forms with easy-to-use validation.

react-hook-form.com

npm install react-hook-form

npm 설치해준 후, 

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

useForm을 사용해보겠습니다. 

 

useForm 기본 사용

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

useForm에서 꺼내 쓰는 세 가지입니다.

  • register : input을 React Hook Form에 등록. {...register("email")} 형태로 spread하면 onChange, onBlur, ref 등이 자동으로 연결됩니다.
  • handleSubmit : 유효성 검사를 통과했을 때만 콜백을 실행해줍니다. 통과 못 하면 콜백 자체가 호출되지 않습니다.
  • formState.errors : 유효성 검사 실패 시 어떤 필드가 왜 실패했는지 담겨 있습니다.

이전에는 useState로 각 필드를 따로 관리하고, onChange도 직접 작성했는데, register를 spread하는 것만으로 그 과정이 전부 대체됩니다.

// 변경 전
<InputText value={email} onChange={(e) => setEmail(e.target.value)} />

// 변경 후
<InputText {...register("email", { required: true })} />

noValidate

<form noValidate onSubmit={handleSubmit(OnSubmit)}>

noValidate를 추가한 이유는 브라우저 기본 유효성 검사(말풍선 팝업)를 끄기 위해서입니다. React Hook Form의 errors로 직접 에러 메시지를 렌더링하기 때문에, 브라우저 기본 UI와 충돌하지 않도록 비활성화합니다.

 

{errors.email && <p className="error-text">이메일을 입력해주세요.</p>}

errors.email은 해당 필드가 유효성 검사를 통과하지 못했을 때만 존재합니다. 단락 평가(&&)로 간단하게 조건부 렌더링할 수 있습니다.

auth API 연동 

📄 auth.api.ts
export const signup = async (userData: SignupProps) => {
    const response = await httpClient.post("/users/join", userData);
    return response.data;
};

SignupProps 타입을 Signup.tsx에서 export해서 여기서도 그대로 재사용하고 있습니다. API 함수와 페이지 컴포넌트가 같은 타입을 공유하기 때문에 타입 불일치 실수를 방지할 수 있습니다.

회원가입 성공 처리 흐름

const OnSubmit = (data: SignupProps) => {
    signup(data).then((res) => {
        showAlert('회원가입이 완료되었습니다.');
        navigate('/login');
    });
};

성공 시 두 가지를 순서대로 처리합니다.

  1. Alert로 완료 메시지를 보여줍니다.
  2. useNavigate로 로그인 페이지로 이동합니다.

useAlert 커스텀 훅

📄 hooks/useAlert.ts

export const useAlert = () => {
    const showAlert = useCallback((message: string) => {
        window.alert(message);
    }, []);

    return showAlert;
};

지금 당장은 window.alert를 그냥 써도 동작은 동일합니다. 그런데 별도 훅으로 분리한 이유는, 나중에 브라우저 기본 alert 대신 커스텀 모달이나 토스트 UI로 교체할 때 useAlert만 수정하면 전체 적용되기 때문입니다. 컴포넌트 코드는 건드리지 않아도 됩니다.

useCallback : 함수를 메모이제이션해서 리렌더링 시 새로 생성되지 않게 하는 훅, 의존성 배열이 []이므로 처음 만든 함수 계속 재사용

전체 흐름 정리

사용자 입력
    ↓
register로 연결된 input이 값을 추적 (useState 불필요)
    ↓
버튼 클릭 → handleSubmit 발동
    ↓
유효성 검사 통과 실패 → errors 업데이트 → 에러 메시지 렌더링
유효성 검사 통과 성공 → OnSubmit 호출
    ↓
signup API 호출 → 성공 시 Alert + 로그인 페이지로 이동