소음 측정 프로젝트(소리담)에서 로그인과 회원가입이 추가됨에 따라 코드가 추가되었다.
|
|
|
위 이미지에 맞춘
해당 회원 가입 코드를 살펴보면 다음과 같다.
import { InputLabel, InputWrapper, MemberInfoInput, Message, MsgWrapper } from "./Input.styles";
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
id: string; // id는 문자열이어야 함
printmsg: boolean; // printmsg는 boolean 타입
edit: boolean;
error?: string; // error는 string이거나 없을 수도 있음
successmsg?: string; // 성공 메시지 (optional)
}
const Input = ({ id, printmsg, edit, error, successmsg, ...props}: InputProps) => {
return (
<InputLabel htmlFor={id}>
<InputWrapper className={edit && error ? "errorInput" : ""}>
<MemberInfoInput id={id} {...props}/>
</InputWrapper>
<MsgWrapper>
{edit && printmsg && error && <Message className="error">{error}</Message>}
{edit && printmsg && successmsg && <Message className="success">{successmsg}</Message>}
</MsgWrapper>
</InputLabel>
);
};
export default Input;
여러개의 input 입력이 나누어짐에 따라 공통 컴포넌트를 input으로 잡아 컴포넌트 코드를 구성했다. 대부분 props를 전달받아 input 속성이 결정되도록 했으며, 비밀번호의 경우 비밀번호 입력과 비밀번호 확인 입력창이 존재하는데 반해 에러 메시지나 성공 메시지가 아래에 한번만 표시됨에 따라 printmsg라는 메시지를 출력할 것인지 여부를 묻는 boolean 속성을 추가했다.
또한 useInput이라는 훅함수를 만들어
import { useState } from "react";
export default function useInput(defaultValue: string, validationFn: (value: string) => boolean) {
const [enteredValue, setEnteredValue] = useState(defaultValue);
const [didEdit, setDidEdit] = useState(false);
const valueIsValid = validationFn(enteredValue);
function handleInputChange(event: React.ChangeEvent<HTMLInputElement>) {
setEnteredValue(event.target.value);
setDidEdit(false);
}
function handleInputBlur() {
setDidEdit(true);
}
return {
value: enteredValue,
handleInputChange,
handleInputBlur,
didEdit,
hasError: didEdit && !valueIsValid,
};
}
이것이 수정 중인지 여부와 validationFn을 받아 해당 input 입력에 맞는 유효성 검사를 진행하도록 했다.
문제가 되는 지점은 회원가입 절차 중 2번째로 email과 비밀번호를 입력하는 단계이다.
import Input from "../../component/signup/input/Input";
import useInput from "../../hook/useInput";
import { useSignupContext } from "../../store/signupContext/SignupContext";
import { isEmail, isEqualsToOtherValue, isNotEmpty, isValidPassword } from "../../util/Validation";
import { EmailInputContainer, InputContainer, InputTitle, MemberInfoContainer, NextBtn, PasswordInputContainer } from "./SignupMember.styles";
// 타입 설정
interface SignupMemberProps {
setCurrentStep: React.Dispatch<React.SetStateAction<number>>;
}
const SignupMember = ({ setCurrentStep } : SignupMemberProps) => {
const { email, setEmail, password, setPassword } = useSignupContext();
const printMessage = true;
const notPrintMessage = false;
const {
value: emailValue,
handleInputChange: handleEmailChange,
handleInputBlur: handleEmailBlur,
didEdit: emailEdit,
hasError: emailHasError
} = useInput(email || "", (value) => {
//...
return isEmail(value) && isNotEmpty(value);
});
// 비밀번호 유효성 검사
const {
value: passwordValue,
handleInputChange: handlePasswordChange,
handleInputBlur: handlePasswordBlur,
didEdit: passwordEdit,
hasError: passwordHasError
} = useInput(password || "", (value) => isNotEmpty(value) && isValidPassword(value));
// 비밀번호 확인 유효성 검사
const {
value: confirmPasswordValue,
handleInputChange: handleConfirmPasswordChange,
handleInputBlur: handleConfirmPasswordBlur,
didEdit: confirmPasswordEdit,
hasError: confirmPasswordHasError
} = useInput("", (value) => isEqualsToOtherValue(value, passwordValue));
const emailSuccessMessage = emailValue && !emailHasError ? "사용가능한 이메일입니다." : "";
const passwordSuccessMessage = passwordValue && !passwordHasError ? "사용가능한 비밀번호입니다." : "";
const confirmPasswordSuccessMessage = confirmPasswordValue && !confirmPasswordHasError && !passwordHasError ? "사용가능한 비밀번호입니다." : "";
const isFormValid = emailEdit && passwordEdit && confirmPasswordEdit && emailSuccessMessage.trim() !== "" && passwordSuccessMessage.trim() !== "" && confirmPasswordSuccessMessage.trim() !== "";
const handleNext = () => {
// 이메일을 SignupContext에 저장
setEmail(emailValue);
// 비밀번호를 SignupContext에 저장
setPassword(passwordValue);
// 다음 단계로 진행
setCurrentStep(3);
}
return (
<MemberInfoContainer>
<InputContainer>
<EmailInputContainer>
<InputTitle>이메일을 입력해주세요</InputTitle>
<Input
id="email"
type="email"
name="email"
placeholder="ex) sorisoop@gmail.com"
onBlur={handleEmailBlur}
onChange={handleEmailChange}
value={emailValue}
printmsg = {printMessage}
edit = {emailEdit}
error={emailHasError ? "사용하실 수 없는 이메일입니다." : undefined}
successmsg={emailSuccessMessage} // 성공 메시지 추가
/>
</EmailInputContainer>
<PasswordInputContainer>
<InputTitle>비밀번호를 입력해주세요</InputTitle>
<Input
id="password"
type="text"
name="password"
placeholder="영문, 숫자 포함 8자이상"
required
minLength={8}
onBlur={handlePasswordBlur}
onChange={handlePasswordChange}
value={passwordValue}
printmsg = {notPrintMessage}
edit = {passwordEdit}
error={passwordHasError ? "사용 불가 비밀번호" : undefined } // 첫 번째 필드는 에러 메시지 X
successmsg={passwordSuccessMessage} // 성공 메시지 추가
/>
<Input
id="confirm-password"
type="text"
name="confirm-password"
placeholder="비밀번호를 다시 입력해주세요"
required
minLength={8}
onBlur={handleConfirmPasswordBlur}
onChange={handleConfirmPasswordChange}
value={confirmPasswordValue}
printmsg = {printMessage}
edit = {confirmPasswordEdit}
error={
confirmPasswordHasError
? "비밀번호가 불일치합니다."
: passwordHasError
? "사용하실 수 없는 비밀번호입니다."
: undefined
}
successmsg={confirmPasswordSuccessMessage}
/>
</PasswordInputContainer>
</InputContainer>
<NextBtn
onClick={handleNext}
disabled={!isFormValid} // 모든 필드가 유효하지 않으면 비활성화
className={isFormValid ? "active" : ""}
>
다음
</NextBtn>
</MemberInfoContainer>
);
};
export default SignupMember;
보다시피 Blur 상태일 때 유효성검사를 진행하도록 설정했으며, 유효성 검사가 통과되지 않을 시 NextBtn, 즉 다음 버튼이 비활성화되도록 설정했는데
유효성 검사도 너무 많은 코드를 작성해야 했으며, 여러 요소를 제어하다 보니 제어의 어려움도 있었고, 코드의 간결성면을 해치고 있어 third party library인 react hook form의 도입을 고려하게 되었다.
이전까지 React Hook form에 대해 사용하지 않은 것은 아니나 이번 기회를 통해 React-Hook-Form을 정확히 이해하고 사용하기를 원하는 마음에서 글을 작성하게 되었다.
유효성 검사를 쉽게 할 수 있는, 성능이 우수하고 유연하며 확장 가능한 form을 제공하는 라이브러리이다. 만들어진 목적 자체가 불필요한 계산을 방지하고, 필요시 component의 re-rendering을 방지하는 등 여러 기능에 따라 성능을 개선하며 사용자에게 보다 좋은 경험을 제공할 수 있다.
state의 최소화
기존의 input과 form 태그를 활용하여 입력을 제어할 때는 필수적이다시피 useState를 활용하여 상태를 관리하였으나 React Hook Form의 컨셉이 비제어 컴포넌트이다 보니 state를 최소화할 수 있습니다. setState 는 기본적으로 비동기로 작동하는데, 이로 인해서 발생하는 버그가 최소화 되고, 랜더링 횟수가 최소화 됨으로 써 퍼포먼스 최적화에 큰 이점을 준다.
소스코드의 간결화
어떤 state 혹은 함수를 사용해 폼을 구현하는것이 아니라 form 자체를 직접 다루고 validation, error등의 기능을 제공하다 보니 코드가 간결화되고 보다 직관적으로 이해할 수 있는 코드로 변화한다.
여기서 제어 컴포넌트와 비제어 컴포넌트에 대해 이해할 필요가 있다.
React에서 form element(input, textarea, select 등)를 다룰 때, 값(value)을 상태(state)로 관리하는 방식에 따라 제어 컴포넌트(Controlled Component) 와 비제어 컴포넌트(Uncontrolled Component) 로 나뉜다
제어 컴포넌트(controlled component)란 React 상태(state)를 통해 입력값을 관리하는 방식으로 입력 값이 변경될 때마다 onChange 핸들러를 통해 React 상태를 업데이트함으로써 “신뢰 가능한 단일 출처 (single source of truth)“로 만들어 두 요소를 결합할 수 있다는 장점을 지니고 있지만 값이 React의 상태에 의해 제어되므로 잦은 리렌더링이 발생할 수 있다는 점입니다.
비제어 컴포넌트(Uncontrolled component)의 경우 리액트의 상태가 아닌 DOM 자체에서 값을 관리하는 방식을 말합니다.
이때 useRef를 활용하여 DOM요소의 값을 직접 가져오며 입력 값이 DOM에 의해 제어되므로 성능이 더 나을 수 있다는 장점을 지니지만 입력 값을 제어하기 어려워, 입력 값을 실시간으로 검증하거나 조작하는 것이 어렵다는 단점 또한 지니고 있다.
react hook form에는 여러가지가 있지만 그 중에서도 useForm 훅함수에 대해 알아볼 것이다.
npm 설치 : npm install react-hook-form
yarn 설치 : yarn add react-hook-form
import { useForm } from "react-hook-form";
const {
handleSubmit,
register,
watch,
formState: { errors },
} = useForm({
mode: "onChange",
defaultValues: {},
});
위 코드에서도 알 수 있듯이 useForm 훅 함수내로 여러가지 configuration 옵션이 들어갈 수 있지만, 그 중에서도 mode와 defaultValues 옵션을 자주 활용한다.
우선 mode부터 살펴보면 공식문서에는 다음과 같이 작성되어 있다.
Validation strategy before submitting behaviour.
useForm 사용방법_React-Hook-Form
즉 submit하기 전 Validation 전략을 설정하는 옵션으로
onSubmit, onBlur, onChange, onTouched, all prop이 존재한다.
몇몇을 살펴보면
onSubmit
submit 이벤트시 validation이 트리거되며 자체적으로 input과 onChange 이벤트 리스너와 연동하여 재검증할 때 사용되는 옵션이다.
onBlur
onBlur 이벤트시, 즉 입력창에 Blur 이벤트가 발생했을 때 Validation이 트리거된다.
onChange
각 입력에서 onChange 이벤트시 Validation이 트리거된다.
특히! 주의해야 할 점은 mode 를 onChange 에 놨을 때 다수의 리렌더링이 발생할 수 있어 성능에 영향을 끼칠 수 있다고 한다.
defaultValues prop은 form의 기본값을 제공하는 옵션이다. 주의해야 할 점은 react-hook-form 을 사용할 때 기본값을 제공하지 않는 경우 input 의 초기값은 undefined 로 관리가 된다.
useForm은 리턴받는 객체에 여러 함수와 객체들이 들어있으며,
이에 따라 구조 분해 할당(destructuring)을 통해 자주 사용된다.
그 중 기본적인 몇몇가지를 살펴보고자 한다.
리턴받는 객체 내 정보 중 하나인 register 메소드이다.
register 메소드를 통해, input 또는 select element를 등록하고
유효성 검사 규칙을 지정할 수 있다.
<input
type="text"
{...register("email", {
pattern: {
value:
/^[0-9a-zA-Z]([-_.]?[0-9a-zA-Z])*@[0-9a-zA-Z]([-_.]?[0-9a-zA-Z])*.[a-zA-Z]{2,3}$/i,
message: "이메일 형식에 맞지 않습니다.",
},
})}
/>;
register 함수의 첫 번째 매개 변수로는 참조할 요소로 name을 준다.
즉 검사할 속성의 이름인 것이다.
두 번째 매개변수로는 options 객체를 받는데 해당 객체에는 유효성 검사를 위한 property들이 들어갈 수 있다.
또한 위 코드에서도 알 수 있듯이 value만을 사용해 유효성을 설정할 수도 있지만 value와 message로 이루어진 객체를 전달함으로써 해당 error에 대한 구체적인 메시지를 줄 수도 있다.
form에서 데이터를 입력한다면 사용자는 submit 버튼을 누른다. 이때 submit 이벤트가 발생하게 되는데, 우리는 서버에 데이터를 넘기기 전에 해당 데이터에 대한 검증을 끝낼 필요가 있다. 그러기 위해서 form 태그의 onSubmit 에 handleSubmit 이라는 함수를 넣어주고 매개변수로 우리가 정의한 onSubmit 함수를 넣어줍니다. onSubmit 함수를 정의 할 때 매개변수로 data 라는 값을 받을 수 있는데, 해당 값은 사용자가 제출 버튼을 클릭 한 후 내려오는 사용자 입장에서 최종으로 제출하는 데이터 입니다.
cosnt { handleSubmit } = useForm();
const onSubmit = (data: IForm) => {
// data 가 최종 데이터
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Row>
<Label>name: </Label>
<ControlInputText<IForm>
control={control}
name="name"
rules={{
required: "반드시 입력해주세요",
maxLength: { value: 10, message: "최대 10글자 입력이 가능합니다." },
}}
/>
{errors.name ? <p className="error">{errors.name?.message}</p> : null}
</Row>
<button>Submit</button>
</form>
);
}
onSubmit 에서는 기본 이벤트를 막아주는 event.preventDefault() 를 할 필요가 없다.
watch는 특정 input을 감시하고 입력된 value를 반환하는 method이다.
즉, watch 함수는 폼에 입력된 값을 구독하여 실시간으로 체크할 수 있게 해주는 함수이다. 주의해야 할 점은 defaultValues가 정의되지 않을 경우 첫 rendering시에 submit되지 않았기 때문에 undefined를 반환한다.
formState는 객체로써 전체 form에 대한 정보를 가지고 있다.
그 중 자주 사용되는 것들을 적어보고자 한다.
isSubmitted
양식이 submit된 이후 true로 변경된다.
reset method가 호출될 때까지 true를 유지한다.
isSubmitting
현재 양식이 submit되고 있는 중이라면 true, 아니면 false
isValid
form에 error가 없으면 true로 설정된다.
setError는 isValid formstate에 영향을 끼치지 않으며
isValid는 항상 전체 form 양식의 validation 결과를 통해 결정된다.
errors
field error가 있는 object.
import { useForm } from "react-hook-form";
import { useSignupContext } from "../../store/signupContext/SignupContext";
import { isEmail, isEqualsToOtherValue, isNotEmpty, isValidPassword } from "../../util/Validation";
import { EmailInputContainer, InputContainer, InputLabel, InputTitle, InputWrapper, MemberInfoContainer, MemberInfoInput, Message, MsgWrapper, NextBtn, PasswordInputContainer } from "./SignupMember.styles";
// 타입 설정
interface SignupMemberProps {
setCurrentStep: React.Dispatch<React.SetStateAction<number>>;
}
interface FormValues {
email: string;
password: string;
confirmPassword: string;
}
const SignupMember = ({ setCurrentStep } : SignupMemberProps) => {
const { setEmail, email, setPassword, password } = useSignupContext();
const {
register,
handleSubmit,
watch,
formState: { errors, isValid, touchedFields }
} = useForm<FormValues>({
mode: "onBlur",
defaultValues: {
email: email || "",
password: password || "",
confirmPassword: ""
}
});
const passwordValue = watch("password");
const onSubmit = (data: FormValues) => {
setEmail(data.email);
setPassword(data.password);
setCurrentStep(3);
};
return (
<MemberInfoContainer onSubmit={handleSubmit(onSubmit)}>
<InputContainer>
<EmailInputContainer>
<InputTitle>이메일을 입력해주세요</InputTitle>
<InputLabel htmlFor="email">
<InputWrapper className={errors.email ? "errorInput" : ""}>
<MemberInfoInput
id="email"
type="email"
placeholder="ex) sorisoop@gmail.com"
{...register("email", {
required: "이메일을 입력해주세요.",
validate: (value) =>
isEmail(value) && isNotEmpty(value) || "사용하실 수 없는 이메일입니다.",
})}
/>
</InputWrapper>
<MsgWrapper>
{errors.email && <Message className="error">{errors.email?.message}</Message>}
{touchedFields.email && !errors.email && <Message className="success">사용가능한 이메일입니다.</Message>}
</MsgWrapper>
</InputLabel>
</EmailInputContainer>
<PasswordInputContainer>
<InputTitle>비밀번호를 입력해주세요</InputTitle>
<InputLabel htmlFor="password">
<InputWrapper className={errors.password ? "errorInput" : ""}>
<MemberInfoInput
id="password"
type="text"
placeholder="영문, 숫자 포함 8자 이상"
{...register("password", {
required: "비밀번호를 입력해주세요.",
validate: (value) =>
isNotEmpty(value) && isValidPassword(value) || "사용하실 수 없는 비밀번호입니다.",
})}
/>
</InputWrapper>
</InputLabel>
<InputLabel htmlFor="confirmPassword">
<InputWrapper className={errors.confirmPassword ? "errorInput" : ""}>
<MemberInfoInput
id="confirmPassword"
type="text"
placeholder="비밀번호를 다시 입력해주세요"
{...register("confirmPassword", {
required: "비밀번호를 다시 입력해주세요.",
validate: (value) => {
if (!isEqualsToOtherValue(value, passwordValue)) {
return "비밀번호가 불일치합니다.";
} else if (!isValidPassword(passwordValue)) {
return "사용하실 수 없는 비밀번호입니다.";
}
return true;
},
})}
/>
</InputWrapper>
<MsgWrapper>
{errors.password && <Message className="error">{errors.password.message}</Message>}
{errors.confirmPassword && <Message className="error">{errors.confirmPassword.message}</Message>}
{touchedFields.confirmPassword && !errors.confirmPassword && <Message className="success">사용가능한 비밀번호입니다.</Message>}
</MsgWrapper>
</InputLabel>
</PasswordInputContainer>
</InputContainer>
<NextBtn
type="submit"
disabled={!isValid}
className={isValid ? "active" : ""}
>
다음
</NextBtn>
</MemberInfoContainer>
);
};
export default SignupMember;
register 메서드를 통해 각 input Element에 대해 유효성 검사를 진행하였고, 유효성 검사인 유틸 함수인 isEmail(=> @가 들어갔는지 확인), isNotEmpty(=> input.value가 비어있는지) 등을 활용하여 진행하였다.
뿐만 아니라 현재 서비스 상황상 회원 가입 절차는 3단계로 진행되고 있으며, 다음 버튼을 누를 시 context를 통해 email이나 password가 저장되고, 회원가입 단계에서 이전 버튼을 누를 시 context에 값이 존재한다면 input 요소에 해당 값을 채워넣기 위해 defaultValue를 활용하였다.
이전 코드에서는 input 요소를 공용 컴포넌트로 분리를 이루었으나 분리된 input Element를 사용한 페이지에 useForm을 활용한 순간
Warning: Function components cannot be given refs. Attempts to access this ref will fail. Did you mean to use React.forwardRef()?
라는 에러가 발생하였다.
알아보니 forwardRef를 사용하지 않았기 때문에 발생한 에러였다.
forwardRef?????
처음 들어본 용어였다.
우선 react-hook-form은 내부적으로 ref를 통해 input 요소를 조작하려는 원리를 지니고 있다.
즉 useForm()을 사용해서 등록할 때(register를 사용해서 input element를 조작할 때 첫번째 인수로 name을 전달할 때) ref를 넘기는데 과거에 ReactJS에서는 ref를 props로 사용할 수 없었다. 이에 따라 React의 모듈로써 forwardRef를 사용해서 ref를 받아왔다.
React에서 현재는 ref를 props로 받아올 수 있다.
단순히 에러가 발생했던 것은 전달했던 ref를 공용 input 컴포넌트의 속성에 전달하지 않아서 발생한 것으로 파악된다.
forwardRef 업데이트_ReactJS v19
그러나 코드 작성 당시 forwardRef를 사용하는 것이 좀 더 복잡해 보이기도 했고, 오히려 input을 그대로 페이지에 사용하는 것이 코드가 좀 더 직관적으로 보여서 forwardRef를 사용하지 않았지만, useForm을 사용할 시 input 또는 select element가 별도의 공용 컴포넌트로 분리가 되어 있다면 ref 속성을 전달하는 것이 필요하다.