vanillaJS|TS로 react-hook-form처럼 react form관리 만들기
2024/03/11
19 min read
DEVELOPMENT
REACT
들어가면서
안녕하세요 반갑습니다. 오늘은 vanillaJS|TS로 form관리 툴을 만드는 주제로 글을 작성했어요
리액트에서 form을 관리하는건 정말 귀찮은 일 같아요. input당 useState로 만들어 제어를 하거나 ref로 비제어를 하고 onChange함수를 만들어 값이 변동 되었을 때마다 할당 해주는 핸들링 함수를 만들어야한다는지 많은 보일러 플레이트가 필요로 해요
이러한 귀찮고 복잡한 form 관리를 해결하기 위해서 react-hook-form 라이브러리를 줄 곧 사용해 왔는데요. 라이브러리가 어떻게 form관리를 하는지 궁금했고, react-hook-form은 많은 기능을 지원하지만 간단한 기능만 제공한다면 직접 만들어 볼 수 있지 않을까 해서 react-hook-form을 참고하여 간단하게 만들어 보며 라이브러리가 폼관리를 어떻게 하고 있는지 알아본내용을 정리하고 공유해보려고 합니다.
소스보러가기 코드만 보고 싶은 분은 해당 Gist에서 확인하실 수 있습니다.
react-hook-form을 참고하였기 때문에 비슷한 부분도 있지만 다른 부분도 있으니 참고 부탁드립니다.
개발의 편의성을 생각해서 Typescript로 작성을 했습니다. JS로직만 보셔도 무방합니다.
createForm
핵심 로직이 담긴 함수입니다.
상태관리
react-hook-form에는 더 다양한 상태들을 관리하지만 완벽히 react-hook-form을 만드는 것이 아니라 간단하게 구현해보는 것이기 때문에 다음과 같이 4개의 상태만을 관리합니다.
필요한 데이터를 보관하는 공간들을 살펴볼게요
- _fields
form관련한 정보들이 들어있는 객체입니다. form의 ref정보나 해당 form에 관련한 옵션들이 있습니다. - _options
createForm을 생성할 때 입력한 option값입니다. - _formState
form에 대한 상태 정보들을 가지고 있는 객체입니다.
errors, isDirty, isValid 등의 상태를 말합니다. - _formValue
form의 값들이 저장되어있는 객체입니다.
기본 타입과 인터페이스
Type이 많아 길어질 수 있어 핵심 부분만 작성하였습니다. 자세한 코드는 소개부분에 작성한 Gist를 통해서 확인할 수 있어요.
form의 모든 내용을 담고 있는 _fields 의 type은 다음과 같습니다
1
type FieldRefs = Record<string, Field>;
field의 type을 먼저 살펴볼게요
1
type Field = {2
_f: {3
ref: Ref;4
refs?: Ref[];5
name: string;6
} & RegisterOption;7
};
field는 form의 등록 정보를 담고있기 때문에 제어를 위한 ref와 등록이름, 그리고 form을 등록할 때 option들을 가지고 있어요.
Ref의 type은 다음과 같습니다 CustomElement를 확장해서 사용하고 있어요
1
type FieldElement =2
| HTMLInputElement3
| HTMLSelectElement4
| HTMLTextAreaElement5
| (Partial<HTMLElement> & {6
type?: string;7
name: string;8
value?: any;9
checked?: boolean;10
});11
12
type Ref = FieldElement;
RegisterOption에 대해 알아볼게요
1
type ValidateFunction = (value: any) => boolean;2
type ValidateWithMessage = {3
value: ValidateFunction;4
message: string;5
};6
type Validate = ValidateFunction | Record<string, ValidateWithMessage>;7
8
type RegisterOption = Partial<{9
validate: Validate;10
onChange: ChangeEventHandler;11
required: boolean;12
disabled: boolean;13
}>;
간단한 form 관리 이기 때문에 validate, onChange, required, disabled 옵션을 받기로 하였습니다.
createForm에 대한 option값 type입니다.
간단한 폼 관리이기 때문에 초기값 설정을 위한 defaultValues기능만 있습니다
1
export type UseFormProps<TFieldValues> = Partial<{2
defaultValues: TFieldValues;3
onStateChange?: Dispatch<FormState>;4
}>;
onStateChange의 경우에는 추후에 react에서 적용하면서 필요한 setState함수입니다.
react-hook-form 에서도 동일하게 setState를 통해서 업데이트를 하고 있습니다.
하지만 react-hook-form은 observer 패턴을 이용하여 구독하고 업데이트를 진행합니다.
주요 매서드
제가 만든 form관리에서 지원하는 매서드는 다음과 같습니다
- register
- handleSubmit
- setValue
register
form을 등록하는 함수입니다.
register는 form을 등록할 이름과 option을 받아서 등록합니다.
코드를 먼저 봐볼게요
1
const register: UseFormRegister = (name, options = {}) => {2
let field = _fields[name];3
if (field) {4
const newOption = {5
_f: {6
...(field && field._f ? field._f : { ref: { name } }),7
name,8
...options,9
},10
};11
_fields[name] = newOption;12
}13
14
return {15
...(options.disalbed ? { disabled: options.disalbed } : { disabled: false }),16
name,17
onChange,18
ref: (ref: HTMLInputElement | null): void => {19
if (ref) {20
register(name, options);21
field = _fields[name];22
23
const fieldRef =24
typeof ref.value === 'undefined'25
? ref.querySelectorAll26
? (ref.querySelectorAll('input,select,textarea')[0] as Ref) || ref27
: ref28
: ref;29
30
const radioOrCheckbox = ref.type === 'radio' || ref.type === 'checkbox';31
const refs = field?._f.refs || [];32
33
if (34
radioOrCheckbox35
? refs.find((option: Ref) => option === fieldRef)36
: fieldRef === field?._f.ref37
) {38
return;39
}40
41
if (_formValue[name]) {42
if (ref.type === 'radio' || ref.type === 'checkbox') {43
if (Array.isArray(_formValue[name])) {44
if (_formValue[name].includes(ref.value)) {45
ref.checked = true;46
}47
} else if (ref.value === _formValue[name]) {48
ref.click();49
ref.checked = true;50
}51
} else {52
ref.value = _formValue[name];53
}54
}55
56
const newField = {57
_f: {58
...field?._f,59
name,60
...options,61
...(radioOrCheckbox62
? {63
refs: [64
...refs.filter((value) => value instanceof Element),65
fieldRef as HTMLInputElement,66
],67
ref: { type: fieldRef.type, name },68
}69
: { ref: fieldRef }),70
},71
};72
73
_fields[name] = newField;74
}75
},76
};77
};
한 번에 보기에는 길어서 차근차근 살펴볼게요
1
let field = _fields[name];2
if (field) {3
const newOption = {4
_f: {5
...(field && field._f ? field._f : { ref: { name } }),6
name,7
...options,8
},9
};10
_fields[name] = newOption;11
}
처음에는 form이 등록이 되어있는지 확인합니다. 있다면 필드의 설정을 업데이트 합니다.
그다음 return문을 통해 form에 적용하는 부분을 볼게요
1
return {2
...(options.disalbed ? { disabled: options.disalbed } : { disabled: false }),3
name,4
onChange,5
};
먼저 disabled 옵션을 적용시켜주는 모습을 볼 수 있습니다. required도 있지만 required를 하게되면 브라우저단에서 error tooltip이 나오게 됨으로 빼고 적용하였습니다.
그다음 name과 onChange 이벤트를 달아줍니다. (onChange의 경우 다음 매서드에 다루고 있어요)
그다음 핵심이 되는 ref 부분입니다. ref를 통해서 form을 제어하고 있기 때문에 아주 중요해요!
1
ref: (ref: HTMLInputElement | null): void => {2
if (ref) {3
register(name, options);4
field = _fields[name];5
6
const fieldRef =7
typeof ref.value === 'undefined'8
? ref.querySelectorAll9
? (ref.querySelectorAll('input,select,textarea')[0] as Ref) ||10
ref11
: ref12
: ref;13
14
const radioOrCheckbox =15
ref.type === 'radio' || ref.type === 'checkbox';16
const refs = field?._f.refs || [];17
18
if (19
radioOrCheckbox20
? refs.find((option: Ref) => option === fieldRef)21
: fieldRef === field?._f.ref22
) {23
return;24
}25
26
if (_formValue[name]) {27
if (ref.type === 'radio' || ref.type === 'checkbox') {28
if (Array.isArray(_formValue[name])) {29
if (_formValue[name].includes(ref.value)) {30
ref.checked = true;31
}32
} else if (ref.value === _formValue[name]) {33
ref.click();34
ref.checked = true;35
}36
} else {37
ref.value = _formValue[name];38
}39
}40
41
const newField = {42
_f: {43
...field?._f,44
name,45
...options,46
...(radioOrCheckbox47
? {48
refs: [49
...refs.filter((value) => value instanceof Element),50
fieldRef as HTMLInputElement,51
],52
ref: { type: fieldRef.type, name },53
}54
: { ref: fieldRef }),55
},56
};57
58
_fields[name] = newField;59
}60
},
ref를 통해서 mount되는 시점에 재귀로 호출하여 업데이트를 해주고 등록이 됩니다.
만약 이미 _formValue에 값이 있다면 _formValue에 있던 값을 할당해주게 됩니다.
checkbox, radio는 다르게 다뤄줘야하기 때문에 분기를 해서 처리합니다.
이후에는 _fields에 들어갈 값을 생성합니다.
기존에 있던 field 값을 넣고 checkbox, radio여부에 따라서 ref값을 refs에 저장할지 ref에 저장할지 나뉘게 됩니다.
checkbox와 radio는 하나의 name으로 중복적으로 선택이 가능하기 때문에 어떤 값들을 선택했는지 알기 위해서 refs 배열로 저장을 하게 됩니다.
예시
1
<input2
type='text'3
{...register('email', {4
required: true,5
validate:{6
isEmail:{value:function(value){return isEmail(value)},message:'이메일 형식에 맞지 않습니다.'}7
}8
})}9
/>10
11
<input12
type='checkbox'13
value='1'14
{...register('agree', {15
required: true,16
})}17
/>18
19
<input20
type='checkbox'21
value='2'22
{...register('agree', {23
required: true,24
})}25
/>
이렇게 등록을 하게 되면
1
_fields:{2
email:{3
_f:{4
ref:<input .../>,5
name:'email',6
requried:true,7
validate:{8
isEmail:{9
value:function(value){return isEmail(value)},10
message:'이메일 형식에 맞지 않습니다.'11
}12
}13
}14
},15
agree:{16
_f:{17
ref:{type:'checkbox',name:'agree'},18
refs:[<input ... />, <input ... />],19
required:true20
}21
}22
}
_fields안에 이런식으로 저장이 되게 됩니다
onChange
register에서 반환이 되는 매서드입니다.
1
const onChange: ChangeHandler = (event) => {2
const target = event.target;3
const name = target.name;4
const field = _fields[name];5
if (_formState.errors[name]) {6
delete _formState.errors[name];7
}8
9
if (field?._f.onChange) {10
field._f.onChange(event);11
}12
13
if (field) {14
const fieldValue = getFieldValue(field._f);15
_formValue[name] = fieldValue;16
}17
};
react-hook-form의 onChange는 다양한 기능을 제공하기에 복잡합니다.
이 글에서는 간단한 기능만 제공하기에 생각보다 간단합니다.
에러는 submit시에만 등록이 되기 때문에 그 전까지 입력하는데 에러메세지가 있는건 안 좋을 것 같아서 저는 변동시 에러를 삭제시켜주었습니다.
register 등록 시 onChange option을 추가했는지 확인하고 해당 event를 넘겨 실행시켜줍니다.
그리고 field값이 있을 때 field에 있는 ref를 이용해서 값을 추출해내고 _formValue에 값을 할당해줍니다.
getFieldValue
필드의 값을 가져오는 getFieldValue는 다음과 같이 되어있습니다
checkbox와 radio를 구별해서 값을 배열이나 값이 한 개라면 배열에서 꺼내어 반환합니다. checkbox나 radio가 아닐 때는 target.value로 값을 가져와 반환합니다.
1
const getFieldValue = (_f: Field['_f']) => {2
const { ref } = _f;3
4
if (ref.type === 'checkbox' || ref.type === 'radio') {5
const value: (string | boolean)[] = [];6
const refs = _f.refs as HTMLInputElement[];7
refs.forEach((_ref) => {8
if (_ref.checked) {9
value.push(_ref.value ?? true);10
}11
});12
return value.length > 1 ? value : value[0];13
}14
15
return ref.value;16
};
실제 react-hook-form에서는 모든 input의 type별로 값을 가져오고 있습니다. 저는 간단하게 만들고자 했기에 value값과 checked값으로 값을 가지고있는 것들만 지원하였습니다.
_field에 저장되어있는 값을 인자값으로 받아서 checkbox, radio 여부에 따라 다르게 동작하게 만들어 값을 가져옵니다.
handleSubmit
사용자에게 onValid라는 폼 핸들러를 받아 에러가 없으면 form 데이터를 넘겨 실행시켜줍니다.
1
const handleSubmit: SubmitEvent = (onValid) => async (e) => {2
if (e) {3
if (e.preventDefault) {4
e.preventDefault();5
}6
}7
const fieldValue = _formValue;8
9
Object.entries(_fields).forEach(([key, value]) => {10
if (value?._f.required) {11
if (fieldValue[key] === false || fieldValue[key] === '' || fieldValue[key] === undefined) {12
const error = {13
type: 'required',14
message: '필수값입니다',15
ref: _fields[key]!._f.refs ? _fields[key]!._f.refs![0] : _fields[key]!._f.ref,16
};17
setError(key, error);18
return;19
}20
if (_formState.errors[key] && _formState.errors[key].type === 'required') {21
delete _formState.errors[key];22
}23
}24
//validate 있을 때 적용25
if (value?._f.validate) {26
if (typeof value._f.validate === 'function') {27
if (!value._f.validate(fieldValue[key])) {28
const error = {29
type: key,30
message: '값을 확인해주세요',31
ref: _fields[key]!._f.refs ? _fields[key]!._f.refs![0] : _fields[key]!._f.ref,32
};33
setError(key, error);34
return;35
}36
if (_formState.errors[key] && _formState.errors[key].type === key) {37
delete _formState.errors[key];38
}39
}40
if (typeof value._f.validate === 'object') {41
Object.entries(value._f.validate).forEach(([fnName, fn]) => {42
if (!fn.value(fieldValue[key])) {43
const error = {44
type: fnName,45
message: fn.message,46
ref: _fields[key]!._f.refs ? _fields[key]!._f.refs![0] : _fields[key]!._f.ref,47
};48
setError(key, error);49
return;50
}51
if (_formState.errors[key] && _formState.errors[key].type === fnName) {52
delete _formState.errors[key];53
}54
});55
}56
}57
});58
// useForm에서 넘겨받은 setState 적용59
if (_options.onStateChange) {60
_options.onStateChange({ ..._formState });61
}62
63
if (isEmptyObject(_formState.errors)) {64
await onValid({ ...fieldValue }, e);65
}66
};
등록되어있는 _fields를 돌면서 검증 옵션들을 적용합니다. 현재 제공되는 option은 위에서 만들었다시피 required, validate가 있습니다. 옵션을 통과하지 못한다면 _formState에 errors에 등록을 하게 됩니다.
onStateChange
onStateChange는 react에서 createForm을 사용하기 위한 setState입니다. snapshot처럼 값을 저장하고 있기 때문에 내부에서 값이 변한다고 해도 리렌더링이 되지 않는 이상 react쪽에서는 변동된 값을 확인할 수 없습니다.
useForm에서 formState를
useState로 관리하고 있고 변동이 생길 시 해당 setState로 최신화 시켜주고있습니다.
react-hook-form은 어떻게 적용했는지 살펴볼까요?
react-hook-form에서는 _subjects객체에 개별로 createSubject함수를 호출하여 observer패턴으로 만들어 줍니다.
useSubscribe는 구독을 원하는 subject에 next함수를 추가해주는 함수입니다. 위에서 만든 subject에 구독을 추가합니다. shouldRenderFormState에서는 바로 _updateFormState로 _formState를 업데이트하고 반환되는 값에 따라서 useState로 관리되고 있는 formState를 setState로 업데이트를 해주고 있습니다.
setError는 하나의 에러만 등록이 가능하도록 하였습니다.
1
const setError = (name: string, value: Error) => {2
if (name in _formState.errors) {3
return;4
}5
_formState.errors[name] = value;6
};
errros
다음과 같은 상황일 때 에러가 등록이 된다면 다음과 같이 저장이 됩니다
1
<input {...register('email',{2
validate:{3
isEmail:{4
value:(event)=> isEmail(event.target.value),5
message:'이메일의 형식이 아닙니다.'6
}7
}8
})}/>9
10
_formState:{11
email:{12
type:'isEmail',13
message:'이메일의 형식이 아닙니다.',14
ref:<input ... />15
}16
}
errors는 form의 name을 key값으로 value값으로는 type, message, ref로 이루어져 있어요 type은 해당 옵션의 이름이 될 수 있고, 방금처럼 validate의 key값으로 될 수 있습니다.
validate를 통과했을 때 저장한 key값과 error의 type이 일치할 때만 error를 지우게 하였습니다.
error는 한 개만 등록이 되기 때문에 통과했다고 error를 지우게 된다면 마지막 검증단계만 통과하면 통과가 되는 것 이기 때문에 해당 검증단계에서 발생한 error인지 확인 후 삭제를 해야합니다.
만약 errors가 비어있다면 사용자에게 받은 onValid함수에 _formValue를 넣어 실행합니다.
setValue
간단하게 강제로 _formValue와 등록된 ref에 값을 입력하는 매서드입니다.
1
const setValue = (name: string, value: any) => {2
const field = _fields[name];3
4
if (field) {5
field._f.ref.value = value;6
_formValue[name] = value;7
}8
};
value의 type이 지정한 타입과 동일한 값을 입력하게 끔 type을 작성해야하는데 너무 어렵습니다...
등록한 name과 저장할 value를 받아서 _formValue, ref를 직접 수정합니다.
useForm
React에서 사용하기 위한 hook입니다.
1
export function useForm<TFieldValues extends FieldValues = FieldValues>(2
props: UseFormProps<TFieldValues> = {},3
): UseFormReturn {4
const _formControl = useRef<UseFormReturn | undefined>();5
const [formState, setFormState] = useState({6
errors: {},7
isDirty: false,8
});9
10
if (!_formControl.current) {11
const formMethods = createForm<TFieldValues>({12
...props,13
onStateChange: setFormState,14
});15
16
_formControl.current = {17
...formMethods,18
formState,19
};20
}21
22
return { ..._formControl.current, formState };23
}
기존 createForm에서 넘겨주는 formState를 사용하고 싶지만 값이 최신화가 되지 않습니다. 해당 문제를 해결하기 위해서 useState를 통해서 formState를 관리를 하고 createForm등록시에 setState을 넘겨 최신화 될 수 있도록 합니다.
사용하기
이렇게 모든 과정을 거치면 react-hook-form처럼 사용할 수 있어요
1
function Form() {2
const {3
register,4
handleSubmit,5
formState: { errors },6
} = useForm({defaultValues:{ email: 'handongryong', password: '' }});7
8
const onSubmit = (data:LoginForm) => {9
...생략10
}11
12
return (13
<div>14
<form onSubmit={handleSubmit(onSubmit)}>15
<input16
type='text'17
{...register('email', {18
required: true,19
validate: (event) => isEmail(event.target.value),20
})}21
/>22
{errros.email && <span>{errors.email.message}</span>}23
<input24
type='password'25
{...register('password', {26
required: true,27
validate: (event) => isPassword(event.target.value),28
})}29
/>30
{errros.password && <span>{errors.password.message}</span>}31
<button type='submit'>로그인</button>32
</form>33
</div>34
);35
}
부족한 부분이 있고, 지적할 부분이 있다면 편하게 연락주시면 감사드리겠습니다!