Front-end/React

React HOC (Higher Order Component) 디자인 패턴

biglol 2023. 12. 5. 00:33

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 에서 프로젝트 소스를 보시면 됩니다. 감사합니다.