이번 Deep Dive 시리즈에서는 React-Hook-Form을 살펴볼것입니다. 평소 React-Hook-Form은 어떻게 최소한의 리렌더링으로 폼요소를 사용할 수 있도록 하는것일까? 와 같이 내부동작에 관한 궁금증을 가지고 계셨다면 이번 시리즈를 재미있게 읽으실 수 있을것입니다.
시리즈의 주제가 Deep Dive인만큼 기본개념과 사용법에 대해서는 간단하게 살펴보거나 생략하는 경우가 많을것입니다. 따라서 React-Hook-Form을 처음 접하시거나 많이 사용해보지 않으셨다면 시리즈를 시작하기전에 React-Hook-Form 튜토리얼을 살펴보고 오시는것을 추천드립니다. 시리즈를 이해하는데 많은 도움이 될것입니다.
시리즈의 첫번째 아티클에서는 본격적인 Deep Dive에 들어가기에 앞서 라이브러리를 Deep Dive 할때 도움이 될만한 이야기를 해보려합니다.
라이브러리 이름이 꽤 긴편이라 이후 라이브러리 이름을 언급해야할때는 약어인 RHF으로 표기하겠습니다.
분석에 사용한 RHF의 버전은 v7.53.0입니다.
비제어 컴포넌트 기반으로 구성된 RHF
RHF는 Formik, Redux Form과 같은 다른 Form 라이브러리 대비 성능이 빠르다는 장점이 있습니다. 이러한 장점을 살리기 위해 RHF는 여러 기법을 사용하는데, 가장 근간을 이루는것이 바로 비제어 컴포넌트입니다. 따라서 RHF의 여러 기법이나 로직을 이해하기 위해서는 이에 대해 정확하게 이해해야합니다.
제어 컴포넌트 vs 비제어 컴포넌트
비제어 컴포넌트는 React에서 Form을 사용하는 방법중 하나로, 흔히 제어 컴포넌트와 비교되는 개념입니다. 따라서 비제어 컴포넌트에 대해 정확하게 이해하기 위해서는 제어 컴포넌트에 대해서도 이해해야합니다. 두가지를 같이 살펴보겠습니다.
제어 컴포넌트는 React의 상태를 이용하여 폼의 값을 관리하는것이고, 비제어 컴포넌트는 별도의 상태로 값을 저장하지 않고, 필요할때마다 html 요소에 저장되어있는 값을 가져오는 방식입니다. 아래의 예제코드를 살펴보시면 더욱 쉽게 이해할 수 있을것입니다.
// 제어 컴포넌트
function ControlledComponent() {
// 상태를 이용해 input의 값을 관리합니다.
const [value, setValue] = useState('');
const handleChange = (event) => {
setValue(event.target.value);
if(event.target.value.length> 10){
setError(true)
}
};
const handleSubmit = (event) => {
event.preventDefault();
alert('제출되었어요' + value);
};
return (
<form onSubmit={handleSubmit}>
<label>
Name:
<input type="text" value={value} onChange={handleChange} />
</label>
<button type="submit">Submit</button>
</form>
);
}
// 비제어 컴포넌트
function UncontrolledComponent() {
// ref를 이용해 html요소정보를 가져옵니다.
const inputRef = useRef(null);
const handleChange = (event) => {
setValue(event.target.value);
};
const handleSubmit = (event) => {
console.log(this.inputRef.current.value);
};
return (
<form onSubmit={handleSubmit}>
<label>
Name:
<input type="text" ref={inputRef} />
</label>
<button type="submit">Submit</button>
</form>
);
}
하지만 두 방식 모두 장단점이 존재합니다. 제어 컴포넌트는 입력값을 React의 상태로 관리하기 때문에 유효성검사, 포커스등의 효과를 즉각적으로 적용할 수 있다는 장점이 있으나, 값을 입력할때마다 리렌더링이 매번 발생한다는 단점이 있습니다. 반면 비제어 컴포넌트는 입력값이 변경될때마다 리렌더링이 발생하지는 않으나, 상태를 React에서 관리하지 않기 때문에 유효성검사, 포커스등의 효과를 적용하기 위해서는 추가적인 작업이 필요하기에 자칫 잘못하면 복잡하면서도 성능은 제어 컴포넌트와 유사한 컴포넌트가 탄생할수도 있다는 단점이 존재합니다.
// 제어 컴포넌트
function ControlledComponent() {
const [value, setValue] = useState('');
const handleChange = (event) => {
setValue(event.target.value);
};
const handleSubmit = (event) => {
event.preventDefault();
alert('A name was submitted: ' + value);
};
return (
<form onSubmit={handleSubmit}>
<label>
Name:
<input type="text" value={value} onChange={handleChange} />
</label>
{/* value는 항상 최신 값이므로 조건만 부여해 렌더링합니다.*/}
<span>{value.length>10?"길이가 너무 길어요":null}</span>
<button type="submit">Submit</button>
</form>
);
}
// 비제어 컴포넌트
function UncontrolledComponent() {
const inputRef = useRef(null);
// 에러 관리를 위해 별도의 상태가 필요합니다.
const [error, setError] = useState(false)
// 에러 관리를 위해서 input의 값이 변경될때 상태를 변경해주어야할수 있습니다.
const handleChange = (event) => {
if(event.target.value.length>10){
setError(true);
}else{
setError(false);
}
};
const handleSubmit = (event) => {
console.log(this.inputRef.current.value);
};
return (
<form onSubmit={handleSubmit}>
<label>
Name:
<input type="text" ref={inputRef} onChange={handleChange}/>
</label>
{/* value가 항상 최신값을 반영하지 않으므로 별도 error상태를 바라보아야합니다*/}
<span>{error?"길이가 너무 길어요":null}</span>
<button type="submit">Submit</button>
</form>
);
}
두 방식의 장단점을 고려하였을때 어떤 것을 선택하더라도 성능 측면에서 유의미한 결과를 얻지 못할것이라는 생각이 들수도 있습니다. 하지만 RHF는 성능을 위해서라면 당연히 비제어 컴포넌트를 선택해야한다고 생각하였습니다. 왜냐하면 제어 컴포넌트의 경우 값이 변경되면 무조건 리렌더링이 발생해야하지만, 비제어 컴포넌트의 경우 값이 변경될때 리렌더링이 발생하지 않기 때문에, 에러, 포커스 등 폼 상태를 컴포넌트에 최신화 하는 과정에서 리렌더링을 제어 컴포넌트 만큼만 발생시키지만 않는다면, 최적화한것으로 볼 수 있기 때문입니다. 폼 상태를 항상 최신으로 유지하면서 최소한의 리렌더링을 사용하는 여러 아이디어들을 추후 보게될것입니다.
한편 비제어 컴포넌트 기반으로 구성되어있다고 할지라도 필요에 따라 제어 컴포넌트를 폼요소로 사용할수 있습니다. 제어컴포넌트 지원이 필요한 이유는 MUI와 Ant와 같이 제어컴포넌트로 된 외부 UI 라이브러리와 RHF을 연결하는 경우가 있기 때문입니다. 이때는 Controller 컴포넌트를 이용하여 이를 쉽게 달성할 수 있습니다. 이 부분에 대해서는 마지막 아티클에서 살펴보게 될것입니다.
자주 사용되는 유틸리티 함수
RHF 내부에는 여러 유틸리티 함수들이 있습니다. 이들 중에는 deepEqual
, cloneObject
와 같이 이름만 보고도 어떤 동작을 하는지 예측가능한 함수가 있는 반면 get
, set
, unset
과 같이 이름만으로는 동작을 예측하기 어려운 함수가 있습니다.
이러한 함수들은 RHF의 동작원리를 이해하려면 필수적으로 알아야하지만, 기능을 소개하는 도중에 유틸함수를 살펴보게되면 흐름이 끊길수 있기 때문에 자주 사용되지만 이름만으로는 동작을 예측하기 어려운 함수들을 먼저 살펴보겠습니다.
함수의 동작을 정확하게 이해하실수 있도록 코드 내부분석을 제공하지만, 사실 다음장을 읽기 위해 코드까지 이해할 필요는 없다고 생각합니다. 함수가 어떤 기능을 제공하는지만 이해해도 충분합니다.
get, set, unset
RHF에 html 요소를 등록할때 사용하는 name은 "test"
와 같이 영문 또는 숫자로 구성하는 경우가 많은데, 경우에 따라서는.
이나 []
를 이용하여 객체나 배열을 조회하는 형태로 입력할수 있습니다. 예를 들어
"person.name.firstname[0]"
과 같이 입력하고 필드에 "테스트 입력값"
을 입력하면 다음과 같이 저장됩니다.
{
person:{
name:{
firstname:["테스트 입력값"]
}
}
}
따라서 RHF에서 객체를 조회, 변경, 제거할때는 일반적인 방법의 사용이 불가능힙니다. 왜냐하면 formValue["person.name.firstname[0]"]
,formValue["person.name.firstname[0]"]="변경된 테스트 입력값"
,delete formValue["person.name.firstname[0]"]
와 같은 코드를 통해서 해당 필드의 값을 조작할수 없기 때문입니다. 따라서 RHF는 이러한 형태의 name을 이용하여 객체를 조작하기 위해 객체를 조회, 변경, 제거하는 get
, set
, unset
를 제공하고 있습니다.
get
export default <T>(object: T, path?: string, defaultValue?: unknown): any => {
// 1. 예외 처리
if (!path || !isObject(object)) {
return defaultValue;
}
// 2. 객체 조회
const result = compact(path.split(/[,[\].]+?/)).reduce(
(result, key) =>
isNullOrUndefined(result) ? result : result[key as keyof {}],
object,
);
// 3. 조회 결과 반환
return isUndefined(result) || result === object
? isUndefined(object[path as keyof T])
? defaultValue
: object[path as keyof T]
: result;
};
첫번째 항목에서는 예외처리 작업을 수행합니다. 인자로 들어온 path
가 없거나, object
가 객체가 아닌경우 기본값을 그대로 반환하는것입니다. 왜냐하면 객체의 값을 조회하는 함수에서 object
가 객체가 아니거나 path
가 없다면 작업을 수행할 수 없기 때문입니다.
두번째 항목에서는 path
를 분해하여 객체를 조회합니다. 먼저 path를 .[]
로 분해한뒤, 빈값을 제거합니다. 예를들어 "person['name'].firstname"
의 경우 split
을 사용하면 ['person','name',,'firstname']
이 되고 compact
(배열의 빈값 제거)을 적용하면 ['person','name','firstname']
가 됩니다. 이후 reduce
를 이용하여 객체 내부를 차례로 탐색해 값을 조회합니다. 이때 isNullOrUndefined(result) ? result : result[key as keyof {}]
로직은 result
가 null
이나 undefined
가 아닐때만 객체형태로 조회하기 때문에 사실상 result?.[key as keyof{}]
와 같다고 볼 수 있습니다.
세번째 항목에서는 객체를 조회한 값을 반환하기에 앞서 추가로 발생할 수 있는 예외 상황을 처리합니다. result