Skip to content
Ethan Sup's log
Github

React Form handling 최적화

React2 min read

과거 회사에서 어떻게 폼의 렌더링을 최소화할까 고민하다가 나온 결과물을 과거에 정리해 놓지 않았었다. 그로 인해 폼을 간단하게 사용하는 곳에 예시를 들 코드가 없어서 어떻게 폼 렌더링을 최소화하는가에 대한 설명이 어려웠었다. 그러다 이번에 동아리에서 진행하는 코인 리코드 작업에서 업그레이드해서 다시 만들어보았다. 이는 해당 코드를 어떻게 만들었는지 설명하는 글이며, 미래의 나와 다른 사람들이 도움이 되었으면 한다.

Form에 Controlled Input을 재렌더링이 많이 된다.

React에서 Form 핸들링을 할 때 상태를 들고 있는 Controlled Input을 사용하게 되면 문제를 겪게 된다. 상태를 들고 있는 컴포넌트가 매우 Change 이벤트가 발생할 때마다 렌더링된다는 가장 큰 문제점이 존재한다. 밑은 공식 문서에 기반해서 만든 간단한 Login Form 예시이다.

1import React from 'react';
2
3interface LoginFormValue {
4 id: string;
5 password: string;
6}
7
8const useLoginForm = (initialValue: LoginFormValue) => {
9 const [formValue, setFormValue] = React.useState<LoginFormValue>(initialValue);
10 const handleChangeForm: React.ChangeEventHandler<HTMLInputElement> = (e) => {
11 const { target } = e;
12
13 setFormValue(prevValue => ({
14 ...prevValue,
15 [target.name]: target.value
16 }));
17 };
18
19 return {
20 formValue,
21 handleChangeForm
22 };
23};
24
25// ...
26
27const LoginPage = ({ initialValue }) => {
28 const { formValue, handleChangeForm } = useLoginForm(initialValue);
29 return (
30 <form onSubmit={submitLogin}>
31 <input value={formValue.id} name="id" onChange={handleChangeForm} />
32 <input value={formValue.password} name="password" onChange={handleChangeForm} />
33 <button type="submit">입력</button>
34 </form>
35 );
36};

이 Login Form에서는 onChange이벤트가 일어날 때마다 전체 컴포넌트(LoginPage)가 리렌더링 된다. 전체 컴포넌트 리렌더링이 되는 현상을 어떻게 막을까?

Input에는 두가지 종류가 있다.

리렌더링 방지를 위해 리렌더링이 되는 이유에 대해 알아보자. React에서 설명하는 Input의 종류는 다음과 같다.

  • React에서 상태를 저장하고 렌더링할 때마다 React의 상태를 DOM에 전달하는 Controlled Input(제어 컴포넌트라고 한다)
  • React에서 상태를 저장하지 않고 상태 저장을 DOM에게 맡기고 나중에 상태를 가져오는 Uncontrolled Input(비제어 컴포넌트라고 한다)

그렇다면 React에서 상태를 저장하지 않고 비제어 컴포넌트를 사용하면 Form에서 불필요한 리렌더링을 줄일 수 있을까?

1import React from 'react';
2
3
4interface LoginFormRef {
5 [key: string]: HTMLInputElement | null;
6}
7
8const useLoginForm = () => {
9 const formRef = React.useRef<LoginFormRef>({});
10 const handleSubmitForm: React.FormEventHandler<HTMLFormElement> = (e) => {
11 e.preventDefault();
12 submitLogin({
13 id: formRef.current.id?.value,
14 password: formRef.current.password?.value,
15 })
16 }
17
18 return {
19 formRef,
20 handleSubmitForm
21 };
22};
23
24// ...
25
26const LoginPage = ({ initialValue }: LoginPageProps) => {
27 const { formRef, handleSubmitForm } = useLoginForm();
28 return (
29 <form onSubmit={handleSubmitForm}>
30 <input ref={(ref) => { formRef.current.id = ref; }} name="id" defaultValue={initialValue.id} />
31 <input ref={(ref) => { formRef.current.password = ref; }} name="password" defaultValue={initialValue.password} />
32 <button type="submit">입력</button>
33 </form>
34 );
35};

위와 같이 바꾸면 줄일 수 있다. 이 Login Form에서는 onSubmit이벤트가 일어날 때만 ref에 있는 value를 가지고 submitLogin함수를 수행한다. 그러면 처음 렌더링 이후에는 Login Form은 더 이상 렌더링이 일어나지 않는다.

하지만 현실세계의 Form에서는 제어 컴포넌트가 필요하다.

하지만 현실의 제품에서는 제어 컴포넌트가 필요해진다. 현실에서는 비제어 컴포넌트가 할 수 없는 것들이 너무 많다.

  • 입력 즉시 validate해서 valid한지 표시하기
  • 입력 값이 valid하지 않으면 submit 버튼 비활성화 하기
  • 입력 값 강제하기(ex. 전화번호 입력시 자동으로 하이픈(-)입력)
  • 동적 값 입력(ex. Todo list)
  • 이외에도 할 수 없는 것들이 많다.

출처

그렇다고 입력 요소가 많은 폼에서 모든 요소들을 제어 가능하게 만들면 기기 성능이 나쁜 경우에는 기기의 브라우저가 멈추는 현상까지 발생할 수 있다.

이 문제를 어떻게 해결해야 할까?

제어 컴포넌트를 비제어 컴포넌트로 감싸자.

이 문제를 해결하려면 제어 컴포넌트 안에서만 validate하고 외부에서 onSubmit할 때 forwardRefuseImprativeHandle로 valid한지 값만 넘겨주는 방식을 사용하면 된다.

1import React from 'react';
2import toast from 'any-toast-library';
3
4interface CustomInputRef {
5 value: unknown;
6 valid: string | true;
7}
8
9interface LoginFormRef {
10 [key: string]: HTMLInputElement | CustomInputRef | null;
11}
12
13const isRefCustomInputRef = (
14 elementRef: HTMLInputElement | CustomInputRef | null,
15): elementRef is CustomInputRef => (elementRef !== null
16&& Object.prototype.hasOwnProperty.call(elementRef, 'valid'));
17
18const useLoginForm = () => {
19 const formRef = React.useRef<LoginFormRef>({});
20 const handleSubmitForm: React.FormEventHandler<HTMLFormElement> = (e) => {
21 e.preventDefault();
22 const isCurrentValidEntries = Object.entries(refCollection.current)
23 .map((refValue): [string, string | true] => {
24 if (!refValue[1].ref) return [refValue[0], '오류가 발생했습니다.'];
25 const isCurrentNameValid = isRefCustomInputRef(refValue[1].ref)
26 ? refValue[1].ref.valid
27 : true;
28 return [refValue[0], isCurrentNameValid];
29 });
30 const invalidFormEntry = isCurrentValidEntries
31 .find((entry): entry is [string, string] => entry[1] !== true);
32 if (!invalidFormEntry) {
33 toast.open('error', invalidFormEntry[1]);
34 }
35 submitLogin({
36 id: formRef.current.id?.value,
37 password: formRef.current.password?.value,
38 })
39 }
40
41 return {
42 formRef,
43 handleSubmitForm,
44 };
45};
46
47// ...
48
49const PasswordForm = React.forwardRef<CustomInputRef | null, PasswordFormProps>((props, ref) => {
50 const [password, setPassword] = React.useState('');
51 const [passwordConfirmValue, setPasswordConfirmValue] = React.useState('');
52 React.useImperativeHandle<CustomInputRef | null, CustomInputRef | null>(ref, () => {
53 return {
54 valid: validatePassword(password, passwordConfirmValue),
55 value: password,
56 };
57 }, [password, passwordConfirmValue]);
58 return (
59 <>
60 <input
61 className={cn({
62 [styles['form-input']]: true,
63 [styles['form-input--invalid']]: password.trim() !== '' && password !== passwordConfirmValue,
64 })}
65 onChange={(e) => setPassword(e.target.value)}
66 />
67 <input
68 onChange={(e) => setPasswordConfirmValue(e.target.value)}
69 />
70 {validatePassword(password, passwordConfirmValue)}
71 </>
72 )
73})
74
75// ...
76
77const LoginPage = ({ initialValue }: LoginPageProps) => {
78 const { formRef, handleChangeForm } = useLoginForm();
79 return (
80 <form>
81 <input ref={(ref) => { formRef.current.id = ref; }} name="id" defaultValue={intitalValue.id} />
82
83 <button type="submit">입력</button>
84 </form>
85 );
86};

위 LoginPage는 다음과 같이 동작한다

  1. ID는 비제어 입력 요소(Uncontrolled Input)로서 DOM에게 상태 관리를 맡긴다.
  2. password와 password-confirm은 제어 컴포넌트로 React에서 상태를 관리한다.
  • password input과 password-confirm input이 입력될 때마다 PasswordForm이 렌더링된다.
  • 에러가 있다면 validatePassword로 에러 메세지를 출력한다.
  1. password와 password-confirm이 입력될 때마다 ref 값이 바뀌도록 전달한다.
  • forwardRef가 익명 함수 컴포넌트에 ref를 전달하고 useImprativeHandle을 통해 ref 값을 커스텀하여 외부에 보여준다.
  1. form에서 submit하게되면 모든 CustomInputRef에서 valid가 true인지 확인한다.
  • valid가 string이라면 해당 값을 toast로 출력한다.
  1. valid하다면 ID는 DOM ref에서 value를 갖고오고, password는 CustomInputRef로 되어있는 ref에서 value를 갖고온다.

간단하게 만들어볼 수 있지 않을까?

useLoginForm을 따로 떼서 일반적인 Form에도 사용가능하도록 만들어보자.

1// https://github.com/BCSDLab/KOIN_WEB_RECODE/blob/develop/src/pages/Auth/SignupPage/index.tsx#L51-L94
2
3interface FormType {
4 [key: string]: {
5 ref: HTMLInputElement | CustomFormInput | null;
6 validFunction?: (value: unknown, refCollection: { current: any }) => string | true;
7 }
8}
9
10interface CustomFormInput {
11 value: unknown;
12 valid: string | true;
13}
14
15interface RegisterOption {
16 validFunction?: (value: unknown, refCollection: { current: any }) => string | true;
17 required?: boolean;
18}
19
20interface RegisterReturn {
21 ref: (elementRef: HTMLInputElement | CustomFormInput | null) => void;
22 required?: boolean;
23 name: string;
24}
25
26export interface SubmitForm {
27 (formValue: {
28 [key: string]: any;
29 }): void;
30}
31
32const isRefCustomFormInput = (
33 elementRef: HTMLInputElement | CustomFormInput | null,
34): elementRef is CustomFormInput => (elementRef !== null
35&& Object.prototype.hasOwnProperty.call(elementRef, 'valid'));
36
37const useLightweightForm = (submitForm: SubmitForm) => {
38 const refCollection = React.useRef<FormType>({});
39
40 const register = (name: string, options: RegisterOption = {}): RegisterReturn => ({
41 required: options.required,
42 name,
43 ref: (elementRef: HTMLInputElement | CustomFormInput | null) => {
44 refCollection.current[name] = {
45 ref: elementRef,
46 };
47 if (options.validFunction) {
48 refCollection.current[name].validFunction = options.validFunction;
49 }
50 },
51 });
52 const onSubmit = (event: React.FormEvent) => {
53 event.preventDefault();
54 const isCurrentValidEntries = Object.entries(refCollection.current)
55 .map((refValue): [string, string | true] => {
56 if (!refValue[1].ref) return [refValue[0], '오류가 발생했습니다.'];
57 const isCurrentNameValid = isRefCustomFormInput(refValue[1].ref)
58 ? refValue[1].ref.valid
59 : refValue[1].validFunction?.(refValue[1].ref?.value ?? '', refCollection) ?? true;
60 return [refValue[0], isCurrentNameValid];
61 });
62 const invalidFormEntry = isCurrentValidEntries
63 .find((entry): entry is [string, string] => entry[1] !== true);
64 if (!invalidFormEntry) {
65 const formValue = Object.entries(refCollection?.current).map((nameValue) => {
66 if (isRefCustomFormInput(nameValue[1].ref) || nameValue[1].ref !== null) {
67 return [nameValue[0], nameValue[1].ref.value];
68 }
69 return [nameValue[0], undefined];
70 });
71 submitForm(Object.fromEntries(formValue));
72 return;
73 }
74 toast.open('error', invalidFormEntry[1]);
75 };
76 return {
77 register,
78 onSubmit,
79 };
80};

validFunction을 통해 submit할 때 validate하는 기능과 함께 리팩토링하였다.

이외에도 여러가지 기능을 만들 수 있을 것이다.

  • onChange/onBlur시 revalidate하기
  • defaultValue 부여하기
  • CustomInputRef를 없애고 일반적인 ref를 value만 가지고 있는 것으로 변경 후 valid는 validFunction으로만 관리하기
  • ...

이걸 위해 라이브러리가 있네요.

이것을 위해 만들어진 라이브러리가 있다. 바로 react-hook-form이다.

react-hook-form을 쓰게 되면 이러한 구현 없이 빠르게 비제어 컴포넌트로 감싸는 작업과 함께 성능 최적화를 할 수 있다.

처음부터 react-hook-form을 따로 쓰지 않은 이유는 forwardRefuseImprativeHandle의 사용방법, 그리고 react-hook-form이 어떻게 동작하는지(모든 동작과정을 구현하지는 않았지만) 알고 가면 좋겠다라는 점에서 만들었다.

© 2023 by Ethan Sup's log. All rights reserved.
Theme by LekoArts