React HOC (Higher Order Component) 디자인 패턴
Loado V2 (https://loado-v2.vercel.app/) 로스트아크 시세확인 및 재련시뮬레이션 프로젝트를 진행하면서 여러 타입의 input을 만들어야 할 이유가 있었습니다.
프로젝트 화면
이 프로젝트에서 따로 개발해야 할 Input 컴포넌트는 아래와 같습니다.
- InputDefault -> 기본 input 컴포넌트
- InputDefaultNumber -> 숫자만 입력받을 수 있는 input 컴포넌트
- InputSearch -> input안에 검색 아이콘이 있는 input 컴포넌트
- InputWithIcon -> input과 여러 아이콘을 조합할 수 있는 input 컴포넌트
기본적으로 Semantic UI React라는 UI library를 base로 따로 customizing하여 컴포넌트를 개발했습니다.
이때 제가 컴포넌트를 구현하면서 신경써야 할 부분은 다음과 같습니다
- 각 input 컴포넌트는 값이 변경되었을 때 내부적으로 값이 변경되어야 함
- 값이 변경될 때마다 props로 받은 onChange 콜백을 실행
- ref control을 위해 useImperativeHandle 선언
이를 위해 매 컴포넌트에
const [value, setValue] = useState('');
onChange && onChange(value);
useImperativeHandle(ref, () => {...});
와 같은 코드를 선언해야 했습니다.
이와 같은 문제를 해결하고자 컴포넌트를 감쏴서 공통 로직을 적용할 수 있게 해주는 HOC 디자인 패턴을 도입하게 됩니다.
이 글에선 적용한 방법을 소개하고자 합니다.
우선 Loado V2 프로젝트의 가장 기본이 되는 Input 컴포넌트는 아래와 같이 선언되어 있습니다.
import { forwardRef } from 'react';
import { InputDefaultProps } from './Types';
import { StyledBaseInput } from './Styled';
import InputHOCMain from './hoc/InputHOCMain';
const InputDefault = forwardRef<null, InputDefaultProps>(
(
{
id = '',
className = '',
placeholder = '',
value = '',
onChange = null,
size = 'small',
loading = false,
type = 'default',
readOnly = false,
disabled = false,
maxLength = undefined,
// stretch = false,
error = false,
onEnter = null,
transparent = false,
fluid = false,
},
ref,
) => {
return (
<StyledBaseInput
id={id}
className={className}
loading={loading}
placeholder={placeholder}
ref={ref}
value={value}
onChange={onChange}
size={size}
error={error}
type={`${type === 'default' ? '' : type}`}
readOnly={readOnly}
disabled={disabled}
maxLength={maxLength}
onKeyUp={(evt: KeyboardEvent) => evt.key === 'Enter' && onEnter && onEnter()}
transparent={transparent}
fluid={fluid}
/>
);
},
);
InputDefault.displayName = 'InputDefault';
export default InputHOCMain(InputDefault);
위 소스를 보시면 내부적으로 값을 관리하지 않으며 onChange에 대한 관리도 없으며 useImperativeHandle도 선언하기 않았습니다.
HOC 디자인 패턴을 적용한 InputHOCMain이라는 컴포넌트를 통해 이를 가능토록 했습니다.
우선 타입 정의한 걸 서술하겠습니다
import {
DropdownProps,
Input,
InputOnChangeData,
StrictInputProps,
Dropdown,
} from 'semantic-ui-react';
// ? Types in InputDefault component
export interface InputDefaultProps extends StrictInputProps {
id?: string;
placeholder?: string;
value?: string;
className?: string;
onChange?: (event: React.ChangeEvent<HTMLInputElement>, data: InputOnChangeData) => void;
size?: 'mini' | 'small' | 'large' | 'big' | 'huge' | 'massive';
loading?: boolean;
type?: 'default' | 'password' | 'number';
readOnly?: boolean;
disabled?: boolean;
maxLength?: undefined | number;
ref?: any;
stretch?: boolean;
error?: boolean;
onEnter?: () => void;
clearInputValue?: Function; //
transparent?: boolean; //
fluid?: boolean;
}
type InputTypeOverall = InputDefaultProps; // & 로 더 선언 가능 (여기에선 간단하게 서술하기 위해 하나의 타입만 적용)
interface InputProps extends Omit<InputTypeOverall, 'onChange'> {
onChange?: (value: string) => void;
}
InputDefaultProps는 실제로 InputDefault 컴포넌트가 받을 props의 타입을 정의했습니다.
InputTypeOverall에서 onChange를 omit한 이유는
- Semantic UI React에서 사용하는 Input 컴포넌트의 onChange 타입은
- (event: React.ChangeEvent<HTMLInputElement>, data: InputOnChangeData) => void
- 제가 사용하고 싶은 onChange의 타입은
- onChange?: (value: string) => void;
따라서 Semantic UI react의 onChange를 제거하고 제가 선언한 onChange를 받을 있게끔 해야 합니다.
HOC컴포넌트의 소스는 다음과 같습니다
const InputHOCMain = (InputComponent: React.FC<InputTypeOverall>) => {
const WithInput = (props: InputProps, ref: InputHOCRefMainType) => {
const [inputValue, setInputValue] = useState<string>(props.value || '');
const inputRef = useRef<Input>();
const { onChange } = props;
useEffect(() => {
!isEqual(inputValue, props.value) && setInputValue(props.value || '');
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.value]);
const onChangeFn = useCallback(
(e: ChangeEvent<HTMLInputElement>, data: InputOnChangeData) => {
const onChangeDebounce = (value: string = '') => {
debounce(() => {
onChange?.(value);
}, 50)();
};
const isInputNumber = InputComponent.displayName === 'InputDefaultNumber';
if (isInputNumber) {
const regex = /^[\d,]*$/;
const isMatch = regex.test(e.target.value);
if (!isMatch) {
setInputValue('');
onChangeDebounce();
} else {
const numberWithComma = Number(data.value.replaceAll(',', '')).toLocaleString();
setInputValue(numberWithComma);
onChangeDebounce(numberWithComma);
}
return;
}
setInputValue(data.value);
onChangeDebounce(data.value);
},
[onChange],
);
useImperativeHandle(ref, () => ({
inputElement: inputRef.current,
clear: () => {
setInputValue('');
},
}));
return <InputComponent {...props} ref={inputRef} value={inputValue} onChange={onChangeFn} />;
};
return forwardRef(WithInput);
};
export default InputHOCMain;
코드가 좀 길지만 한번 자세하게 살펴봅시다
- Input 컴포넌트의 내부적인 값을 관리하기 위해 useState선언 및 ref관리를 위해 ref 선언
- InputComponent는 HOC로 감싼 Input 컴포넌트를 뜻함
- 컴포넌트가 받을 onChangeFn 함수 선언
- onChangeFn함수 내부에선 값이 바뀔때마다 onChange 콜백을 실행
- InputDefaultNumber 컴포넌트인지 확인하고 isInputNumber 변수에 담음
- InputDefaultNumber 컴포넌트이면 숫자만 입력받을 수 있게끔하고 천단위에 쉼표를 삽입하고 그게 아니라면 값만 변경
- useImperativeHandle을 선언하여 ref를 통해 InputComponent를 가져올 수 있게끔하고 ref.current.clear()를 통해 값을 비울 수 있게끔 기능 추가. 여기에서 더 추가한다면 focus와 같은 기능을 추가할 수 있습니다.
- forwardRef로 Input 컴포넌트를 감싸기
활용예시 확인
onChange의 value에 마우스를 가져다보면 value는 string타입인 걸 확인할 수 있으며 hoc컴포넌트 안에서 선언한 onChange의 타입과 일치한다는 것을 확인할 수 있습니다.
Ref를 이용해 clear도 잘 작동하는지 살펴봅시다
type InputHOCRefType = { inputElement: Input | undefined; clear: () => void };
const inputRef = useRef<InputHOCRefType>(null);
<InputWithIcon
value={`${refineOverallSetting.honingSuccessRate}`}
fluid={false}
size={'mini'}
onChange={(value) => {
setRefineOverallSetting((prev) => ({
...prev,
honingSuccessRateManual: value,
}));
}}
inputIcon={<PercentIcon />}
type="number"
ref={inputRef}
/>
<button
onClick={() => {
if (inputRef.current) {
inputRef.current.clear();
}
}}
>
값 지우기
</button>
![]() |
![]() |
값 지우기를 클릭하면 2번째 그림처럼 값이 지워지는 것을 확인할 수 있습니다.
이처럼 hoc 디자인패턴을 이용해 공통로직을 매 컴포넌트에 선언할 필요 없이 한번의 선언으로 공통적으로 적용할 수 있습니다.
깃헙 소스를 확인하고 싶으시면 https://github.com/biglol10/loado-v2-frontend 에서 프로젝트 소스를 보시면 됩니다. 감사합니다.