들어가며
안녕하세요 딜리셔스 웹 프론트엔드 개발팀 이경일입니다. react v16.8.0에 등장하여 당시에는 새로운 패러다임이었지만, 이제는 익숙해진 react hook에 관해 이야기 해보겠습니다. 올해 상반기에 개발 완료된 신상스튜디오 내재화 프로젝트에서 폼 관리를 위해 사용한 react-hook-form
을 사례 중심으로 빠르게 소개해 보도록 하겠습니다.
Stateless functional component & Hook
React는 v0.14.0부터 stateless functional component를 지원하고 있었으며 v16.8.0부터 hooks를 제공하여 함수형 컴포넌트 내부에서 상태관리가 가능하게 되었습니다. ( 무려 4년 동안 함수형 컴포넌트는 stateless 였습니다…! )
왜 react는 4년간 지켜온 클래스 컴포넌트의 상태관리 방식을 두고 hooks를 공개했을까요?
- Lifecycle method 에서 상태관리와 관련 없는 로직이 빈번하게 섞이곤 하였습니다. 부가적으로 컴포넌트를 작게 분해가 불가능하여 테스트난이도 또한 상승합니다.
- Class 컴포넌트의 this 문법으로 발생하는 혼동을 제거하기 위함입니다. 태어나서 개발언어로 JavaScript만 사용하신 분이라면 느끼지 못하셨을 수 있겠지만(?), this의 사용법이 다른 언어와는 달라 정확하게 알고 사용하는 게 아니라면, 개발에 혼동을 크게 줄 수 있다는 게 react 개발자들의 판단으로 보입니다.
개인적으로 class 컴포넌트에서 this의 원리를 이해하고 사용해도 컴포넌트 사이즈가 커짐에따라 자연스럽게 발생하는 혼동을 경험했기 때문에 React 개발자들의 노고에 박수를 보냅니다 👏🏻
React에서 hook을 호출하여 사용하는 규칙은 정리하자면 아래와 같습니다.
- 함수형 컴포넌트내 최상위 영역에서 hook을 호출해야 합니다.
- 해당 규칙으로 랜더링마다의 hook 호출 순서가 보장됩니다.
- 조건문, 반복문 안에서 hook을 호출하지 않습니다.
- 중첩된 함수 안에서 hook을 호출하지 않습니다.
- custom hook 내부에서 hook을 호출해야합니다.
- custom hook은 JavaScript 일반함수 입니다.
- custom hook의 함수명은
use
를 prefix로 갖도록 합니다. (예: useFriendState)
기본적인 hook 사용법을 공유하였으니, 본론으로 들어가 보겠습니다.
신상스튜디오 서비스 화면개발
신상스튜디오는 기존에 외주업체를 통해 서비스가 관리되고 있었는데요. 딜리셔스 자체적으로 관리할 수 있도록 내재화 프로젝트를 진행하였고 올해 5월에 서비스 배포가 완료됐습니다. 🎉
딜리셔스 웹 프론트엔드 팀의 신규 프로젝트 건은 react로 개발을 진행하고 있는데요. 신상스튜디오 서비스는 React SPA로 CSR 방식을 사용하고 있습니다. 서비스 내 다양한 페이지 증 유독 까다로운 사용자 입력 로직을 요하는 화면이 있었습니다. 각 화면에서 사용자 입력 관련 요구사항을 정리하면 아래와 같습니다.
- [회원가입]
- 고객이 입력한 정보와 설정한 pattern의 일치 여부 확인(이메일, 전화번호 등)
- validation 상태관리 및 안내 문구 노출 (필수 입력 사항, 비밀번호 일치여부 등)
- [스튜디오 촬영 예약하기]
- 동적 입력 form 생성 및 관리 (촬영제품 수에 따라 입력 폼 동적 생성)
- validation 상태관리 및 안내 문구 노출 (필수 입력 사항 등)
입력값 상태관리 react-hook-form
아래 react-hook-form
패키지에 대한 내용은 v7.27.0 기준으로 작성되었습니다.
여러분은 잠시동안 서비스 회원가입 페이지를 개발하는 (행복한) 프론트엔드 개발자가 되어보겠습니다.
서비스가 아주 단순해서 이름
만으로 회원가입이 가능하다고 가정해보겠습니다.
이때, 회원가입 요청 시 확인해야할 사항이 2가지 있습니다.
- ‘이름’ 값이 적절하게 입력되었는가
- 1번 사항이 위배되었을 때, 어떻게 안내할 것인가
이를 위해서 개발적으로 필요한 사항은 5가지가 있습니다.
- ‘이름’ 값을 상태관리할
name state
[name, setName] = useState("");
- 에러메세지 값을 상태관리할
errorMessage state
[errorMessage, setErrorMessage] = useState("");
- ‘이름’ 값의 정합성을 판단하는
validation function
const validateName = (name) => {
const isValidated = false;
if (name === "") {
setErrorMessage("이름을 입력해주세요.");
} else if (name.length < 2) {
setErrorMessage("이름은 2자 이상 입력해주세요.");
} else {
isValidated = true;
setErrorMessage("");
}
return isValidated;
};
- ‘이름’폼 입력값이 변경되는 이벤트를 관리할
input handler
const nameChangeHandler = (e) => {
const inputValue = e.target.value;
setName(e.target.value);
};
- 회원가입 요청할
submit function
const onSubmit = (e) => {
e.preventDefault(); // 화면 새로고침 방지
const isValidated = validateName(name);
if (isValidated) {
signUp({ name });
}
};
이름
1개 입력을 관리하기위해서 이만큼의 코드작성이 필요합니다. 여기에 아이디
, 이메일
, 비밀번호
, 비밀번호재입력
, 휴대전화번호
, 주소
, 등… 추가요소가 생긴다면 어떨까요?
추가되는 갯수만큼 관리해야하는 state
또한 늘어납니다.
상태관리가 늘어나는 만큼, handler
, validation function
선언도 늘어납니다.
관련하여 안내에 필요한 errorMessage
도 추가될겁니다.
마지막으로 submit function
의 로직이 복잡하고 비대해질 것으로 예상이됩니다.
react-hook-form
은 위에서 언급한 고민에 대한 해결책을 제공합니다.
- register 함수를 통해 입력값 등록 단순화
- handleSubmit 함수를 통해 preventDefault 보일러플레이트 제거 및 함수인자로 form 값 전달
const InputComponent = () => {
const { regster, handleSubmit } = useForm();
const { ...inputProps } = register("name");
// const { name, ref, onChange, onBlur } = inputProps;
const onSubmit = (formValues) => singUp({ ...formValues });
return (
// handleSubmit 함수는, 인자로 전달된 함수에서 바로 입력값을 전달받아 로직을 작성할 수 있도록 합니다.
<form onSubmit={handleSubmit(onSubmit)}>
<input {...inputProps} />
</form>
);
};
react-hook-form
은 비제어 컴포넌트를 활용하고 있기 때문에 ref
에서 register
함수가 실행됩니다. 이러한 접근 방식은 사용자가 타이핑하거나 값을 변경할 때 리랜더링을 줄여줍니다. 제어 컴포넌트가 아니기 때문에 페이지에 컴포넌트가 마운트되는 속도도 훨씬 더 빠릅니다. 또한 register
에 인자로 입력한 문자열은 handleSubmit
콜백 파라미터의 속성명 동일하게 전달되어 api 요청 시 추가매칭이 필요없습니다.
개발 업무를 하며 시간이 지날수록 느끼는 바이지만, 아주 사소한 부분의 반복적인 수동입력으로 발생하는 스트레스는 런타임 에러의 그것과 같다고 느껴집니다. 그러나 안심하세요 입력값 상태명은 camelCase로 선언하되, api payload interface는 snake_case 인경우에 입력 값 매칭에 발생하는 스트레스는 react-hook-form
이 모두 가져갔습니다 👋🏻
아래는 신상스튜디오 [회원가입] 페이지에서 react-hook-form
을 적용한 사례입니다.
- register 함수 실행 시 pattern 옵션에 정규표현식을 전달하여 입력패턴 삽입
const Regex = { email: /^[\w-.]+@([\w-]+\.)+[\w-]{2,4}$/g };
const EamilInput () => {
const { register, formState:{ errors } } = useForm();
const emailRegister = register('email', {
required: { value: true, message: '이메일을 입력해주세요.' },
pattern: { value: Regex.email, message: '이메일 형식을 입력해주세요.' },
});
return (
<form>
<Input {...emailRegister} />
<span>{errors['email']}</span> // required, pattern 조건에 맞춰 message 노출
<form/>
);
}
- register 함수 실행 시 validate 으로 함수를 전달하여 입력제한기능 추가 가능
const PasswordCheckInput () => {
const { register, formState: { errors } } = useForm();
const passwordCheckRegister = register('passwordCheck', {
required: { value: true, message: '비밀번호를 다시 입력해주세요.' },
validate: (changePasswordCheck?: string) => {
if (!changePassword) return;
return changePasswordCheck === changePassword || '비밀번호가 일치하지 않습니다.';
},
});
return (
<form>
<Input {...passwordCheckRegister} />
<span>{errors['passwordCheck']}</span> // required, validate 조건에 맞게 message 노출
<form/>
);
}
2 가지 예시 모두 register 함수로 입력값 등록 시 옵션( required, pattern, validate ) 전달을 통해 formState 객체의 상태값으로 관련 메시지를 구독할 수 있습니다.
서두에 작성한 입력값과 에러메시지를 직접 관리하는 방법보다 더욱 직관적이고 간결한 로직을 보여주고 있습니다.
이 기능을 적용한 신상스튜디오의 회원가입 폼은 아래와 같습니다.
설마 아직 react-hook-form
의 강력함을 느끼지 못하시겠다구요…?
아직 소개시켜드릴 기능이 남아있습니다.
이름하야 useFieldArray
hook은 runtime에 입력 폼을 동적으로 추가, 삭제 할 수 있도록 합니다!
신상스튜디오 서비스에서 촬영 결제금액이 촬영 색상수
에 영향을 받기 때문에 정확한 촬영 색상수
파악이 필요했습니다. 따라서 촬영 색상을 한번에 입력받지 않고, 명시적으로 나눠서 입력할 수 있도록 동적 폼 생성이 필요했습니다.
위와 같은 기능 구현에 useFieldArray는 꼭맞는 자물쇠 열쇠처럼 기능하였습니다.
아래는 useFieldArray
로 색상폼을 동적으로 추가, 삭제하는 기능을 가진 컴포넌트 코드입니다.
- useForm을 통해 폼 값을 초기화합니다.
useFieldArray 함수 인자로 전달하기위해 control 객체도 함께 할당합니다.
const { control, register } = useForm({
defaultValues:{
shootingColors:[{name:''}];
}
});
- useForm에서 할당한 control 객체를 전달하여 useFieldArray를 생성합니다.
배열로 관리하고자하는 폼이름을 name으로 전달합니다.
const { fields, append, remove } = useFieldArray({
control,
name: `shootingColors`,
});
- 폼추가, 삭제를 위한 handler를 작성합니다.
const addColorQuantity = (e) => {
e.preventDefault();
append({ name: "" });
};
// 삭제 handler의 경우, 삭제를 위한 index를 함께 전달합니다.
const subColorQuanaity = (idx) => (e) => {
e.preventDefault();
remove(idx);
};
- useFieldArray에서 할당한 fields를 통해 폼을 랜더링합니다.
const ShootingColorForm = () => {
return (
<>
{fields.map((field, idx) => (
<Input key={field.id} {...register(`shootingColors.${idx}.name`)} />
))}
</>
);
};
작성한 handler 까지 조립이 완료된 컴포넌트 코드는 아래와 같습니다.
const ShootingColorForm = () => {
const { control, register } = useForm({
defaultValues:{
shootingColors:[{name:''}];
}
});
const { fields, append, remove } = useFieldArray({
control,
name: `shootingColors`,
});
// handlers
const addColorQuantity = (e) => {
e.preventDefault();
append({ name: '' });
};
const subColorQuanaity = (idx) => (e) => {
e.preventDefault();
remove(idx);
};
return (
<>
{fields.map((field, idx) => (
<Input {...register(`shootingColors.${idx}.name`)}>
{idx > 0 && (
<button onClick={subColorQuanaity(idx)}>
<CloseIcon />
</button>
)}
</Input>
))}
<Button onClick={addColorQuantity}>
<PlusIcon />
</Button>
</>
);
};
export default ShootingColorForm;
예시로 보여드린 코드에선 상품 색상
관련 폼에 대해서만 동적 생성, 삭제를 보여드렸지만, 실제 서비스에선 색상폼이 소속된 폼 그룹
또한 동적으로 생성, 삭제 할 수 있도록 구현되어 있습니다. ( 폼 동적관리가 중첩되어 기능하고 있습니다. )
맺으며
긴글 읽어주셔서 감사합니다. 이 글은 유용한 기술을 소개하고자 쓰기 시작했지만, 당시 프로젝트 기간을 되돌아볼 수 있는 시간이기도 했습니다. 신상스튜디오 내재화 프로젝트는 올해 1월에 딜리셔스에 합류하고 처음 배포하는 서비스였기에 감회가 새로웠습니다. 함께 힘써주신 신상스튜디오 내재화 프로젝트 구성원들에게 감사의 인사를 전합니다.
3줄요약
- 신상스튜디오가 5월에 내재화가 완료되었습니다.
- 리렌더링이 없는 상태관리를 원한다면 useRef를 활용해보세요.
- react 입력 상태관리에 애로사항이 있다면
react-hook-form
을 사용해보세요:)
이경일
딜리셔스 프론트엔드 개발자
"한걸음 한걸음 확실하게"