[React/Typescript] useForm hook으로 회원가입 구현 (useForm props로 내리기, 첨부 파일 처리)

옹잉·2024년 4월 29일
0
post-custom-banner

로그인 한 번 하고나니까 그래도 좀 수월했는데
input 코드가 너무 반복되어서 컴포넌트로 빼서 했더니 하나가 동작하면 다른게 안되고 그거 고치면 또 다른 문제가 생기고 너무 복잡해져서 그냥 로그인과 비슷하게 구현하려고 수정했다.

강사 회원가입에서는 자식 컴포넌트에서 useForm의 메서드들을 사용하려고 FormProvider로 넘겨주는 방식을 사용했다.
useForm을 내려주고싶은 자식 컴포넌트를 <FormProvider {...methods}> 같은 형식으로 감싸주고, 자식 컴포넌트에서는 useFormContext에서 메서드를 뽑아 쓰면 된다.
(아래의 전체 코드에서 확인 가능)

학생 회원가입 구현은 여차저차 구현 했는데, 강사 회원가입 구현하려다가 학생 회원가입까지 동작을 안하게 됐다 ㅋ
그래서 반쪽짜리지만 학생 회원가입 동작하게 해서 깃헙에 올리고,
첨부파일을 넣는 input 태그 하나 추가했다고 이렇게 고전할 줄 몰랐다...

문제상황 및 해결방법

처음에 입력받은 파일이 하나여서 as File로 타입을 지정했는데 .length 메서드를 사용하거나 [0]번째에 접근하려고 했는데 계속 오류가 발생했다.

log를 찍어보니 FileList 형태로 값이 들어와서
interface.ts에 지정한 File을 FileList로 수정하고, FileList의 [0]번째 접근하는 방식으로 수정했더니 해결됐다.

const onSubmit = (data: SignupData) => {
        const { id, password, nickname, email, authDocument } = data;
        // TODO: tutor의 경우 authDocument가 없을 때 alert 띄우기

        if (role === "tutor" && authDocument) {
            const formData = new FormData();
            formData.append("id", id);
            formData.append("password", password);
            formData.append("nickname", nickname);
            formData.append("email", email);
          // formData.append("authDocument", authDocument as File) (X)
            formData.append("authDocument", authDocument[0])
            signup(role, formData);
        } else {
            const newData = { id, password, nickname, email };
            signup(role, newData);
        }
    };
export interface SignupData {
    id: string;
    password: string;
    nickname: string;
    email: string;
  // authDocument?: File | null; (X) -> List가 아니기 때문에 .length or [0]번째 접근 불가
    authDocument?: FileList | null;
    certification?: number;
}

전체 코드

pages/Signup.tsx

import { RoleProps, SignupData } from "../../types/interface";
import "../../styles/pages/account/signup.scss";
import axios from "axios";
import { Link, useNavigate } from "react-router-dom";
import { FormProvider, set, useForm } from "react-hook-form";
import { useEffect, useState } from "react";
import usePrevious from "../../hooks/usePrevious";
import SignupForm from "../../components/form/SignupForm";

export default function StudentSignup({ role }: RoleProps) {
    const navigate = useNavigate();

  // 부모 컴포넌트에서 FormProvider로 useForm 함수 넘겨주기 위해 methods에 담음
    const methods = useForm<SignupData>({
        mode: "onSubmit",
        defaultValues: {
            id: "",
            password: "",
            nickname: "",
            email: "",
            authDocument: null,
            certification: undefined,
        },
    });

    const { watch, reset } = methods;

    const [isIdChecked, setIsIdChecked] = useState(false);
    const [isNicknameChecked, setIsNicknameChecked] = useState(false);
    const [isCertified, setIsCertified] = useState(false);
    const [randomNum, setRandomNum] = useState(0);

    const navigateAndReset = (path: string) => {
        reset();
        navigate(path);
        setIsIdChecked(false);
        setIsNicknameChecked(false);
        setIsCertified(false);
    };

    const idValue = watch("id");
    const nicknameValue = watch("nickname");
    const emailValue = watch("email");
    const preIdValue = usePrevious(idValue);
    const preNicknameValue = usePrevious(nicknameValue);
    const preEmailValue = usePrevious(emailValue);

    useEffect(() => {
        if (idValue !== preIdValue) setIsIdChecked(false);
        if (nicknameValue !== preNicknameValue) setIsNicknameChecked(false);
        if (emailValue !== preEmailValue) setIsCertified(false);
    }, [idValue, nicknameValue, emailValue, preIdValue, preNicknameValue, preEmailValue]);

    /* axios */
    // 중복 체크(아이디, 닉네임)
    const checkDuplicate = async (keyword: string, value: string): Promise<void> => {
        if (!value) return alert(`${keyword === "id" ? "아이디를" : "닉네임을"} 입력해주세요.`);

        try {
            const url = `${process.env.REACT_APP_API_SERVER}/api/check${
                role === "student" ? "Student" : "Tutor"
            }${keyword === "id" ? "Id" : "Nickname"}?${keyword}=${value}`;

            const res = await axios.get(url);

            if (res.data.available === false) {
                alert(`이미 사용중인 ${keyword === "id" ? "아이디" : "닉네임"}입니다.`);
            } else {
                alert(`사용 가능한 ${keyword === "id" ? "아이디" : "닉네임"}입니다.`);
                keyword === "id" ? setIsIdChecked(true) : setIsNicknameChecked(true);
            }
        } catch (error) {
            console.error("아이디 중복검사 오류", error);
        }
    };

    // 회원가입
    const signup = async (role: string, data: SignupData | FormData) => {
        if (!isIdChecked || !isNicknameChecked)
            return alert("아이디와 닉네임 모두 중복 확인을 해주세요.");

        if (!isCertified) return alert("이메일 인증을 해주세요.");

        if (data instanceof FormData) {
            const newFormData = new FormData();
            data.forEach((value, key) => {
                const newKey = key === "authDocument" ? "auth" : key;
                newFormData.append(newKey, value);
            });

            const nickname = newFormData.get("nickname") as string;

            try {
                const res = await axios({
                    method: "post",
                    url: `${process.env.REACT_APP_API_SERVER}/api/${role}`,
                    data: newFormData,
                    headers: { "Content-Type": "multipart/form-data" },
                });

                alert(`${res.data}! ${nickname}님 환영합니다🎉\n로그인 페이지로 이동합니다.`);
                navigate("/login");
            } catch (error) {
                console.error("회원가입 오류", error);
            }
        } else {
            try {
                const res = await axios.post(
                    `${process.env.REACT_APP_API_SERVER}/api/${role}`,
                    data
                );

                alert(`${res.data}! ${data.nickname}님 환영합니다🎉\n로그인 페이지로 이동합니다.`);
                navigate("/login");
            } catch (error) {
                console.error("회원가입 오류", error);
            }
        }
    };

    // 이메일 인증
    const sendEmail = async (email: string) => {
        const res = await axios.post(`${process.env.REACT_APP_API_SERVER}/api/sendEmail`, {
            email,
        });
        setRandomNum(res.data.randomNum);
    };

    // 인증번호 확인
    const checkCertification = (certification: number) => {
        if (!certification) return alert("인증번호를 입력해주세요.");

        if (certification === randomNum) {
            alert("인증되었습니다.");
            setIsCertified(true);
        } else {
            alert("인증번호가 일치하지 않습니다.");
        }
    };

    return (
        <section>
            <div className="signup_container">
                <h2>{role === "student" ? "학생 " : "강사 "} 회원가입</h2>
                <div className="go_to_other_sign_up">
                    {role === "student" ? (
                        <button type="button" onClick={() => navigateAndReset("/signup/tutor")}>
                            강사로 가입하기
                        </button>
                    ) : (
                        <button type="button" onClick={() => navigateAndReset("/signup/student")}>
                            학생으로 가입하기
                        </button>
                    )}
                </div>
                <span>이미 계정이 있으신가요?</span>
                &nbsp;
                <Link to="/login">로그인</Link>
                <FormProvider {...methods}>
                    <SignupForm
                        role={role}
                        signup={signup}
                        checkDuplicate={checkDuplicate}
                        sendEmail={sendEmail}
                        checkCertification={checkCertification}
                    />
                </FormProvider>
            </div>
        </section>
    );
}

components/form/SignupForm.tsx

import { useFormContext } from "react-hook-form";
import { RoleProps, SignupData } from "../../types/interface";
import PasswordInput from "../input/PasswordInput";
import "../../styles/components/form/signupForm.scss";

// signup, checkDuplicate 함수를 props로 받아옴
interface SignupFormProps {
    checkDuplicate: (keyword: string, value: string) => Promise<void>;
    signup: (
        role: string,
        data: FormData | { id: string; password: string; nickname: string; email: string }
    ) => Promise<void>;
    sendEmail: (email: string) => Promise<void>;
    checkCertification: (certification: number) => void;
}

export default function SignupForm({
    role,
    signup,
    checkDuplicate,
    sendEmail,
    checkCertification,
}: SignupFormProps & RoleProps) {
    const {
        register,
        handleSubmit,
        watch,
        formState: { errors },
    } = useFormContext<SignupData>();

    const onSubmit = (data: SignupData) => {
        const { id, password, nickname, email, authDocument } = data;
        // TODO: tutor의 경우 authDocument가 없을 때 alert 띄우기

        if (role === "tutor" && authDocument) {
            const formData = new FormData();
            formData.append("id", id);
            formData.append("password", password);
            formData.append("nickname", nickname);
            formData.append("email", email);
            formData.append("authDocument", authDocument[0]);
            signup(role, formData);
        } else {
            const newData = { id, password, nickname, email };
            signup(role, newData);
        }
    };

    return (
        <>
            <div className="signup_wrapper">
                <div className="basic_signup">
                    <form name="signup_form" onSubmit={handleSubmit(onSubmit)}>
                        <div className="signup_id">
                            <label htmlFor="id">ID</label>
                            <input
                                type="text"
                                {...register("id", { required: "아이디를 입력해주세요." })}
                                id="id"
                                autoComplete="username"
                            />
                            <button type="button" onClick={() => checkDuplicate("id", watch("id"))}>
                                중복 확인
                            </button>
                        </div>
                        <div className="signup_pw">
                            <label htmlFor="pw">비밀번호</label>
                            <PasswordInput
                                type="password"
                                {...register("password", { required: true })}
                                id="pw"
                            />
                        </div>
                        <div className="signup_nickname">
                            <label htmlFor="nickname">닉네임</label>
                            <input
                                type="text"
                                {...register("nickname", { required: true })}
                                id="nickname"
                            />
                            <button
                                type="button"
                                onClick={() => checkDuplicate("nickname", watch("nickname"))}

                                중복 확인
                            </button>
                        </div>
                        <div className="signup_email">
                            <label htmlFor="email">이메일</label>
                            <input
                                type="email"
                                {...register("email", { required: true })}
                                id="email"
                            />
                            <button type="button" onClick={() => sendEmail(watch("email"))}>
                                인증번호 발송
                            </button>
                        </div>
                        <div className="signup_certification">
                            <label htmlFor="certification">인증번호</label>
                            <input
                                type="number"
                                placeholder="숫자만 입력해주세요."
                                {...register("certification", { required: true })}
                                id="certification"
                            />
                            <button
                                type="button"
                                onClick={() => checkCertification(Number(watch("certification")))}

                                인증 확인
                            </button>
                        </div>
                        {role !== "student" && (
                            <div className="sign_up_auth_document">
                                <label htmlFor="auth_document">증빙 자료</label>
                                <input
                                    type="file"
                                    id="auth_document"
                                    accept=".jpg, .jpeg, .png, .pdf"
                                    {...register("authDocument", { required: true })}
                                />
                            </div>
                        )}

                        <button type="submit">회원가입</button>
                    </form>
                </div>
            </div>
        </>
    );
}

types/interface.ts

export interface RoleProps {
    role: "student" | "tutor";
}

export interface SignupData {
    id: string;
    password: string;
    nickname: string;
    email: string;
    authDocument?: FileList | null;
    certification?: number;
}

components/input/PasswordInput.tsx

import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faEye, faEyeSlash } from "@fortawesome/free-regular-svg-icons";
import React, { forwardRef, useState } from "react";
import "../../styles/components/input/password.scss";

interface PasswordProps extends React.HTMLProps<HTMLInputElement> {
    type: string;
}

const PasswordInput = forwardRef<HTMLInputElement, PasswordProps>(({ type, ...rest }, ref) => {
    const [isShow, setIsShow] = useState(false);
    const handleClick = () => {
        setIsShow(!isShow);
    };

    return (
        <>
            <div className="pw_input_wrapper">
                <input
                    ref={ref}
                    type={isShow ? "text" : type}
                    autoComplete="current-password"
                    {...rest}
                />
                <div className="eye_icon" onClick={handleClick}>
                    <FontAwesomeIcon icon={isShow ? faEyeSlash : faEye} />
                </div>
            </div>
        </>
    );
});

export default PasswordInput;

참고 자료
[React Hook Form] props drilling 줄이기 | useFormContext, FormProvider

profile
틀리더라도 🌸🌈🌷예쁘게 지적해주세요💕❣️
post-custom-banner

0개의 댓글