라우터 등록
라우터에 경로를 등록해야 합니다.
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 이라는 라이브러리를 사용해보고자합니다.
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 연동
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');
});
};
성공 시 두 가지를 순서대로 처리합니다.
- Alert로 완료 메시지를 보여줍니다.
- 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 + 로그인 페이지로 이동
'Devcourse > BookShop 사이드 프로젝트' 카테고리의 다른 글
| [BookShop-FE] 본격적인 UI 작업 시작 - 헤더와 푸터 (0) | 2026.04.17 |
|---|---|
| [BookShop- FE] React 스타일링 셋업 & 테마 시스템 (0) | 2026.04.14 |
| [BookShop 프로젝트] 백엔드 마무리하기 (0) | 2026.03.18 |
| [BookShop 프로젝트] jwt 토큰 추가, jwt 예외처리, try...catch, throw (0) | 2026.03.04 |
| [BookShop 프로젝트] 주문 API - LAST_INSERT_ID(), MAX()로 최신 데이터 가져오기, insertId, 벌크 INSERT (0) | 2026.02.20 |