일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | |||
5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 | 31 |
- 포이마웹
- 백준구현문제
- 프로그래머스JS
- HTML
- 백준구현
- JS프로그래머스
- 안드로이드 스튜디오
- CSS
- 코딩테스트
- 백준nodejs
- 백준
- css기초
- 익스프레스
- 프로그래머스코테
- 백준골드
- 자바스크립트
- 알고리즘
- dp알고리즘
- 다이나믹프로그래밍
- 코테
- 몽고DB
- JS
- 리액트
- HTML5
- 리액트댓글기능
- 리액트커뮤니티
- 백준js
- js코테
- 백준알고리즘
- 프로그래머스
- Today
- Total
개발새발 로그
리액트 패턴 - Controlled and Uncontrolled Component Patterns(+ useForm 최적화하기) 본문
리액트 패턴 - Controlled and Uncontrolled Component Patterns(+ useForm 최적화하기)
이즈흐 2024. 7. 13. 23:52📖들어가며..
제어 컴포넌트와 비제어 컴포넌트는 리액트를 사용하면서 많이 들어봤을 것이다.
나는 제어컴포넌트란 리액트의 상태로 관리되는 컴포넌트를 의미하는 것이고,
비제어 컴포넌트는 그 반대의 의미라고 이해하고 있다.
제어, 비제어 컴포넌트 패턴을 보면서 그 내용을 더 깊게 이해해보려고 한다.
🤔제어 컴포넌트(Controlled Component)란?
제어 컴포넌트는 React에 의해 값이 제어되는 폼 엘리먼트입니다.
컴포넌트의 값은 항상 프로퍼티를 통해 명시적으로 설정되고 콜백을 통해 업데이트됩니다.
즉, 컴포넌트의 상태는 항상 입력 데이터와 동기화되므로 React가 컴포넌트의 동작을 제어할 수 있고 개발자가 사용자 입력을 쉽게 처리할 수 있습니다.
🤔제어컴포넌트를 사용할 경우는?
- 폼 입력값을 실시간으로 검증해야 할 때.
- 입력값에 따라 다른 컴포넌트에 영향을 줄 때.
- 여러 입력 필드의 상태를 하나의 상태 객체로 관리해야 할 때.
- 입력값을 기반으로 동적 UI를 렌더링해야 할 때.
🤔비제어 컴포넌트(Uncontrolled Component)란?
비제어 컴포넌트는 브라우저에서 값을 관리하는 폼 엘리먼트입니다.
즉, 컴포넌트의 값은 사용자가 설정하고 React는 그 동작을 제어하지 않습니다.
따라서 복잡한 형태의 사용자 입력을 처리하기가 더 어려울 수 있지만, 간단한 형태에서는 더 빠르고 쉽게 사용할 수 있습니다.
🤔비제어 컴포넌트를 사용하는 경우:
- 단순한 폼이나 입력 필드가 많지 않을 때.
- 초기값만 설정하고, 이후 입력값을 많이 사용하지 않을 때.
- 외부 라이브러리와의 통합이 필요할 때 (특히 jQuery 기반의 라이브러리).
- 상태 관리의 복잡성을 피하고 싶을 때
👀제어 컴포넌트에 대한 고민
보통 제어컴포넌트나 비제어 컴포넌트는 회원가입 기능을 만들 때 고민할 것 같다.
나는 제어 컴포넌트, 비제어 컴포넌트 패턴을 공부하면서 내가 개발한 훅이 생각났고, 이를 어떻게 풀어내야할까 고민했다.
실제로 나는 회원가입 기능을 만들기 위해 useForm이라는 커스텀 훅을 만들었는데
이때 form의 값을 React의 useState 상태로 관리했다.
이유는
1. 이메일, 사용자 이름, 비밀번호가 타이핑될 때마다 유효성검사를 실시하고, 화면에 결과가 나타나야 했다.
2. 각 이메일, 사용자 이름, 비밀번호의 유효성 검사가 타이핑이 끝남에 따라 모두 성공하면 회원가입 완료 버튼이 활성화되어야 했다.
3. 비밀번호와 비밀번호확인 값은 서로 타이핑될 때마다 유효성 검사가 실시되어야 했다.
근데 제어 컴포넌트의 문제는 위에서 말했듯이 "각 값이 React의 상태로 관리되기 때문에 리-렌더링이 된다."라는 문제가 있었다.
특히 나는 useForm이라는 훅을 직관적으로 사용할 수 있도록 아래와 같이 구성했다.
const { fields, validationStatus, isFormComplete, handleFieldChange } =
useForm({
initialValues: {
email: '',
password: '',
username: '',
confirmPassword: '',
},
initialValidationStatus: {
email: false,
password: false,
username: false,
confirmPassword: false,
},
validate: {
email: value => isValidEmail(value),
password: value => isValidPassword(value),
username: value => isValidUsername(value),
confirmPassword: (value, values) => value === values.password,
},
dependencies: {
password: ['confirmPassword'],
},
});
일단 해당 훅의 문제점은
1. 명령적으로 코드가 구성되어있다.
2. 객체로 관리되고있어 하나의 데이터가 변경되면 모든 컴포넌트가 리-렌더링된다.
해당 방법을 해결하기 위해서는
1. 비제어 컴포넌트 방식으로 변경한다.
2. 객체 구조를 유지하되 메모이제이션을 활용한다.
와 같은 방법이 있었다.
비제어컴포넌트로 변경하기에는
1. 타이핑마다 실시간으로 유효성검사를 실시해야한다.
2. 모든 유효성검사가 성공하면 타이핑이 끝날 때 자동으로 회원가입 완료 버튼이 활성화되어야한다.
같은 조건 때문에 현 상태를 유지해야했다.
🤔그러면 어떻게 해야할까?
먼저 useForm훅을 살펴보자
먼저 기존의 훅을 어떻게 사용하는지 보자.
1. form에 필요한 모든 데이터(이메일, 이름, 비밀번호, 비밀번호 확인)들을 객체로 받아온다.
2. form에 필요한 모든 데이터의 검증 초기 값(이메일, 이름, 비밀번호, 비밀번호 확인)들을 객체로 받아온다.
2. form의 데이터에 필요한 유효성검사(이메일, 이름, 비밀번호, 비밀번호 확인)들을 객체로 받아온다.
3. form에서 나타날 수 있는 의존성(비밀번호와 비밀번호확인)들을 객체로 받아온다.
위처럼 받아온 데이터를 useForm 내부에서 처리하는 것이다.
useForm내부에서는?
useForm 내부가 어떻게 되는지 설명하자면
1. 받아온 form 데이터 객체를 useState로 만들어준다. ( fields )
2. 받아온 form 데이터 검증 초기값 객체를 useState로 만들어준다.( validationStatus )
3. 받아온 form 데이터 유효성 검사 객체는 onChange 이벤트가 수행될 때 각 form 데이터에 맞는 key값으로 실행한다.
- 4. onChange가 발생한 데이터는 받아온 key값에 따라 fileds에서 해당 값만 업데이트 된다.
- 5. 이때 만약 dependencies 객체에 등록된 key라면 해당 dependencies에 등록된 key들도 모두 유효성 검사를 수행하도록 합쳐준다.
- 6. 모든 유효성 검사를 수행했다면 해당 결과를 validationStatus 객체에 업데이트 해준다.
- 7. validationStatus 가 변경됐을 때 모든 유효성 검사를 확인하는 isFormComplete 상태를 업데이트해준다.
쉽게 말하면 객체로 데이터가 관리되고,
유효성검사도 객체로 관리돼서
각각 대응되는 키값을 이용해 유효성 검사를 수행하고,
dependencies와 같은 예외상황도 처리해준다.
마지막으로 validationStatus 가 업데이트되면 isFormComplete도 업데이트 해주는 것이다.
🤔문제가 뭐지?
이제 useForm에서 만든 데이터나 함수를 아래와 같이 사용한다.
여기서 문제는
form 데이터가 객체 하나로 관리되기 때문에 객체안의 데이터 하나가 바뀌면 상위 컴포넌트는 리-렌더링된다.
✨이 문제를 해결하기 위해서 어떻게 해야할까?
1. FormField 컴포넌트를 메모이제이션한다.
상위 컴포넌트가 리-렌더링되지만 props 값이 같다면 리-렌더링되지않도록 하면된다.
그럼 props가 같은 때 리-렌더링이 되지 않도록 할 수 있을 것이다.
2. 하지만 props로 받아오는 onChange도 함수이기 때문에 메모이제이션 해야한다.
useForm 내부에서 onChage함수도 useCallback으로 메모이제이션해준다.
3.그러면 vaildateFiled 함수(지금은 checkFieldValidity)도 메모이제이션 해줘야한다.
이 에러는 checkFieldValidity 함수가 useCallback의 의존성 배열에 포함된 변수를 참조하면서 매 렌더링 시마다 변경되고 있다는 것을 의미합니다.
이로 인해 useCallback이 매번 새로운 함수를 생성하게 되어 성능에 문제가 생길 수 있습니다.
해결 방법은 checkFieldValidity 함수를 useCallback 내부로 이동시키거나, checkFieldValidity를 별도의 useCallback 훅으로 감싸는 것입니다
그래서 useCallback으로 함수를 감싸주면된다.
4. useCallback으로 vaildateFiled 함수를 감싸주었지만 결과는 똑같아진다.
의존성 배열 경고에 해당하는 모든 데이터를 넣어줬기 때문이다.
form 유효성검사, form 검증 결과값, dependencies 모두 객체이기 때문에 데이터 하나만 변경이 되더라도
validateField는 재선언될 것이고, useCallback으로 감싼 handleFieldChange도 다시 재선언돼서 메모이제이션이 의미가 없어질 것이다.
그러면 handleFieldChange함수를 받는 FormField는 props받는 함수가 바뀌었으니까 리-렌더링될 것이다.
5. 그래서 form 유효성검사, form 검증 결과값, dependencies를 useRef로 관리해야한다.
useRef로 관리하게 되면 변경되어도 리-렌더링을 일으키지 않는다.
또한 useCallback의 의존성 배열에도 넣지 않아도 의존성 배열 경고가 발생하지 않게 된다.
6. 추가로 이메일은 중복확인 로직도 필요하고, 중복확인 버튼 컴포넌트도 메모이제이션 해줘야한다.
나는 회원가입 기능에서 이메일 중복확인 기능도 있기 때문에 아래와 같이 중복확인 버튼에도 메모이제이션이 필요하다.
FormField 컴포넌트에서 props로 컴포넌트를 받을 수 있도록 했기 때문이다.
📢실행결과
먼저 이전에는 어땠는지 보자
최적화 이후에는 아래처럼 깔끔하게 렌더링이 안일어난다!
📖전체 로직
✨useForm 커스텀 훅 - 회원가입 Model 부분
interface FormFields {
[key: string]: string;
}
interface FieldsValidationStatus {
[key: string]: boolean;
}
interface FieldValidators {
[key: string]: (value: string, fields: FormFields) => boolean;
}
interface UseFormParams {
initialValues: FormFields;
initialValidationStatus: FieldsValidationStatus;
validate: FieldValidators;
dependencies?: { [key: string]: string[] };
}
const useForm = ({
initialValues,
initialValidationStatus,
validate,
dependencies = {},
}: UseFormParams) => {
const [fields, setFields] = useState<FormFields>(initialValues);
const validationStatusRef = useRef<FieldsValidationStatus>(
initialValidationStatus,
);
const validateRef = useRef<FieldValidators>(validate);
const dependenciesRef = useRef<{ [key: string]: string[] }>(dependencies);
const [isFormComplete, setIsFormComplete] = useState(false);
const checkFieldValidity = useCallback(
(fieldName: string, fieldValue: string): void => {
setFields(prevFields => {
const updatedFields = { ...prevFields, [fieldName]: fieldValue };
let fieldsToValidate = [fieldName];
if (dependenciesRef.current[fieldName]) {
fieldsToValidate = fieldsToValidate.concat(
dependenciesRef.current[fieldName],
);
}
const updatedValidationStatus = { ...validationStatusRef.current };
fieldsToValidate.forEach(fieldToValidate => {
updatedValidationStatus[fieldToValidate] = validateRef.current[
fieldToValidate
](updatedFields[fieldToValidate], updatedFields);
});
const validationResult = allFieldsValid(updatedValidationStatus);
setIsFormComplete(validationResult);
validationStatusRef.current = updatedValidationStatus;
return updatedFields;
});
},
[],
);
const handleFieldChange = useCallback(
(e: ChangeEvent<HTMLInputElement>): void => {
const { name, value } = e.target;
checkFieldValidity(name, value);
},
[checkFieldValidity],
);
const allFieldsValid = (status: FieldsValidationStatus): boolean => {
return Object.values(status).every(value => value);
};
return {
fields,
validationStatus: validationStatusRef.current,
isFormComplete,
handleFieldChange,
};
};
export default useForm;
✨FormField 컴포넌트 - 회원가입 form View 부분
import { memo, ReactNode } from 'react';
import Group from '~/common/components/Group';
import Input from '~/common/components/Input';
import Text from '~/common/components/Text';
interface FormFieldProps {
type: string;
name: string;
label: string;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
value: string;
isValid: boolean;
errorMessage?: string;
placeholder: string;
right?: ReactNode;
}
const FormField = memo(
({
type,
name,
label,
onChange,
value,
isValid,
errorMessage,
placeholder,
right,
}: FormFieldProps) => {
return (
<Group direction="columns" spacing="sm" grow={true} className="relative">
<label htmlFor={name}>
<Text size="small" elementType="span">
{label}
</Text>
</label>
<div className="relative w-full">
<Input
type={type}
name={name}
onChange={onChange}
placeholder={placeholder}
value={value}
className="h-11 w-full pr-10"
/>
{right}
{value && !isValid && (
<Text className="text-sm text-error">
{errorMessage || `올바른 ${label}을(를) 입력하세요.`}
</Text>
)}
</div>
</Group>
);
},
);
export default FormField;
✨회원가입 페이지 - 비즈니스 로직 부분
interface RegisterFormProps {
mutation: { isPending: boolean };
onRegisterCompleted: (isValid: boolean) => void;
handleSubmit: (e: React.FormEvent<HTMLFormElement>) => void;
}
const RegisterForm = ({
mutation,
onRegisterCompleted,
handleSubmit,
}: RegisterFormProps) => {
const { data: userList } = useUserListQuery();
const {
emailDuplicateCheckMessage,
setIsEmailDuplicate,
setEmailDuplicateCheckMessage,
isEmailDuplicate,
checkDuplicateEmail,
} = useEmailDuplicate({ userList });
const { fields, validationStatus, isFormComplete, handleFieldChange } =
useForm({
initialValues: {
email: '',
password: '',
username: '',
confirmPassword: '',
},
initialValidationStatus: {
email: false,
password: false,
username: false,
confirmPassword: false,
},
validate: {
email: value => isValidEmail(value),
password: value => isValidPassword(value),
username: value => isValidUsername(value),
confirmPassword: (value, values) => value === values.password,
},
dependencies: {
password: ['confirmPassword'],
},
});
const MemoizedDuplicateButton = useMemo(
() => (
<DuplicateButton
checkDuplicateEmail={checkDuplicateEmail}
email={fields.email}
isEmailDuplicate={isEmailDuplicate}
validationStatusEmail={validationStatus.email}
/>
),
[
checkDuplicateEmail,
fields.email,
isEmailDuplicate,
validationStatus.email,
],
);
useEffect(() => {
setIsEmailDuplicate(true);
setEmailDuplicateCheckMessage('');
}, [fields.email, setEmailDuplicateCheckMessage, setIsEmailDuplicate]);
useEffect(() => {
onRegisterCompleted(isFormComplete && !isEmailDuplicate);
}, [isFormComplete, isEmailDuplicate, onRegisterCompleted]);
return (
<form onSubmit={handleSubmit} className="pb-[100px]">
<Group direction="columns" spacing="md" grow={true}>
<div>
<FormField
type="email"
name="email"
label="이메일"
placeholder="이메일을 입력해주세요."
onChange={handleFieldChange}
value={fields.email}
isValid={validationStatus.email}
right={MemoizedDuplicateButton}
/>
{emailDuplicateCheckMessage && (
<Text
className={
isEmailDuplicate ? 'text-sm text-error' : 'text-sm text-success'
}
>
{emailDuplicateCheckMessage}
</Text>
)}
</div>
<FormField
type="text"
name="username"
label="이름"
placeholder="이름을 입력해주세요."
onChange={handleFieldChange}
value={fields.username}
isValid={validationStatus.username}
errorMessage={ERROR.NAME_INVALID}
/>
<FormField
type="password"
name="password"
label="비밀번호"
placeholder="비밀번호를 입력해주세요."
onChange={handleFieldChange}
value={fields.password}
isValid={validationStatus.password}
errorMessage={ERROR.PASSWORD_INVAILD}
/>
<FormField
type="password"
name="confirmPassword"
label="비밀번호 확인"
placeholder="비밀번호를 다시 한번 입력해주세요."
onChange={handleFieldChange}
value={fields.confirmPassword}
isValid={validationStatus.confirmPassword}
errorMessage={ERROR.PASSWORD_NOT_MATCH}
/>
<div className="sticky w-full p">
<Button
loading={mutation.isPending}
fullwidth={true}
disabled={mutation.isPending || isEmailDuplicate || !isFormComplete}
>
회원가입
</Button>
</div>
</Group>
</form>
);
};
export default RegisterForm;
+ ✨컴포넌트 이전 props값과 현재 props값이 같은지 다른지 확인하는 방법
위처럼 컴포넌트에 등록하면 이전 props값과 현재 props값을 알 수 있다.
신기했다!
+✨컴포넌트 props로 내릴때 메모이제이션은 어떻게 해야하나?
부끄럽지만 이 부분에서 애를 먹었다.
계속 컴포넌트를 이렇게 메모이제이션하고있었는데 안돼서 왜 그런지 찾아보니
컴포넌트를 메모이제이션할 때는 useMemo를 사용하는 것이 적합하다고 한다.
생각해보니 컴포넌트는 함수고 그 결과를 출력하는 거니까..
'React' 카테고리의 다른 글
Webpack을 설정하면서.. (0) | 2024.07.31 |
---|---|
React - 회원가입 기능을 구현하면서 (0) | 2024.07.27 |
리액트 패턴 - Disabled prop Pattern (0) | 2024.07.12 |
React - TabMenu 기능을 만들 때 어떻게 해야할까? (0) | 2024.07.12 |
React - Eslint + Prettier 설정하기 (1) | 2024.06.11 |