Input 컴포넌트 설계 및 개발기

Seungrok Yoon (Lethe)·2023년 8월 8일
1
post-thumbnail

Input 컴포넌트를 만들어보자!

아직 마구잡이로 작성중이라 내용이 매끄럽지 않습니다.

Input 컴포넌트는 무엇을 하는 컴포넌트일까?

Input 컴포넌트는 HTML <input/> 을 함수형 리액트 컴포넌트로 만든 컴포넌트이다. 그렇기에 Input 태그의 기능을 사용할 수 있도록 해야한다.

Input은 정말 많이 쓰이는 UI 컴포넌트이다. 이름, 이메일, 휴대전화 번호, 날짜 등등... 유저가 데이터를 입력할 수 있는 란을 제공해주는 태그이다.

먼저 이 기본 기능을 충실히 반영하는 컴포넌트를 만들고, 점진적으로 부가기능들을 추가해보자.

그런데 어떻게 만들지...?


작성하는 방법도 가지각색이다. 코드에는 정답이 없으니까.
생각만 하면 머리만 복잡해지니 가장 간단한 함수형 컴포넌트를 적어보았다. 아직 기능이 하나도 없는 비어 있는 Input 컴포넌트이다.

export default function Input() {
  return <input />;
}

기본 기능 추가


내가 만들려는 Input 컴포넌트는 스타일은 나중에 emotion/styled로 다시 적용할 예정이다.

따라서 스타일은 고려하지 않고, 기본 기능만 넣어 보았다.

의도한 사항은 아래와 같다.

  • 네이밍은 BaseInput으로 하여 최종 프로덕트에서 직접적으로 사용되지 않을 것임을 명시하였다.
    • BaseInput 말고, InputBaseComponent로 네이밍을 변경하였다. (last update - 2023.08.10)
  • 상위 컴포넌트에서 <input>에 대한 조작이 가능하도록 forwardRef를 이용해 ref를 연결해주었다. 이제 컴포넌트가 마운트 되기 전에 <input>태그와 전달받은 ref 객체의 current 프로퍼티가 연결될 것이다.
  • props는<input>의 모든 속성을 다 받을 수 있도록 HTMLAttributes<HTMLInputElement> 타입으로 지정해주었다.

코드는 아래와 같이 작성했다.

import { HTMLAttributes, MutableRefObject, forwardRef } from 'react';

interface InputBaseComponentProps extends HTMLAttributes<HTMLInputElement> {}

/**
 *
 * @description 가장 기본적인 Input 태그를 JSX로 변환한 컴포넌트
 */
export const InputBaseComponent = forwardRef(function BaseInput(
  props: InputBaseComponentProps,
  ref: MutableRefObject<HTMLInputElement>
) {
  return <input {...props} ref={ref} />;
});

흐음...이제 다음은 어떡한담...?

내가 원하는 기능을 선별하기


(last update - 2023.08.10)

참조: https://mui.com/material-ui/react-text-field/

MUI는 참 편리한 기능들을 많이 넣어놨다. 따라해볼까?

  • 라벨과 값을 함께 다룰 수 있으면 좋겠다.

  • controlled 도 지원하고, uncontrolled도 지원했으면 좋겠다.

  • 입력 값이 필수인지, 활성화는 되어있는지, 어떤 종류의 input인지 type을 적용할 수 있으면 좋겠다. Form props

  • type="number"인 경우에는 화폐-금액표시 포매팅이 적용되었으면 좋겠다.

  • type="phone"인 경우에는 국가번호 + 그에 해당하는 전화번호 포매팅이 적용되었으면 좋겠다.

https://github.com/patternfly/patternfly-org/issues/1271

Select?

TextField 컴포넌트 만들기 - 1

InputBaseComponent의 상위호환 컴포넌트인 TextField 컴포넌트를 만들 차례다. Input이 가지고 있는 여러 기능들을 구현해놓은 컴포넌트이다. 사실상 UI개발시에는 InputBaseComponent가 아닌, TextField 컴포넌트를 사용하게 될 것이다.

const TextField = forwardRef(function Input(
  props: TextFieldProps,
  ref: MutableRefObject<any> | RefObject<any>
) {
  return (
    <InputBaseComponent
      {...props}
      onChange={(e: ChangeEvent<HTMLInputElement>) => {
        props.onChangeValue(e.target.value);
      }}
      ref={ref}
      type="text"
    />
  );
});

첫 TextField 컴포넌트를 위처럼 작성해 보았다.

<input>의 onChange 프롭과 차별점을 주기 위해 onChangeValue를 주었다.

그런데 두 가지 에러가 발생했다.

Property 'value' does not exist on type 'EventTarget'.

=> 이벤트 핸들러 함수가 받는 이벤트 객체는 ChangeEvent<HTMLInputElement>타입이어야하는데, 확인결과 FormEvent<HTMLInputElement>에서 발생한 문제였다.

이벤트 타입이 이렇게 지정된 이유는 내가 처음에 InputBaseComponent의 타입지정을 이리했기 때문이다.

interface InputBaseComponentProps extends HTMLAttributes<HTMLInputElement> {}

HTMLAttributes<T>는 내부적으로 DOMAttributes<T>를 상속하고 있는데, 이 DOMAttributes<T>가 문제였다.

 interface HTMLAttributes<T> extends AriaAttributes, DOMAttributes<T>  

그렇다면 DOMAttributes에는 무엇이 있을까? onChange타입이 들어있다. 보이다시피 FormEventHandler타입이다.

아하... input태그 속성의 타입지정은 HTMLAttributes<HTMLInputElement>로 하면 안되는구나...를 깨달았다.

그래서 InputBase컴포넌트의 타입을 InputHTMLAttributes<HTMLInputElement>로 변경해 주었다.

import { InputHTMLAttributes, MutableRefObject, forwardRef } from 'react';

type InputBaseComponentProps = InputHTMLAttributes<HTMLInputElement>;

/**
 *
 * @description 가장 기본적인 Input 태그를 JSX로 변환한 컴포넌트
 */
export const InputBaseComponent = forwardRef(function BaseInput(
  props: InputBaseComponentProps,
  ref: MutableRefObject<HTMLInputElement>
) {
  return <input {...props} ref={ref} />;
});
 interface InputHTMLAttributes<T> extends HTMLAttributes<T> {
        accept?: string | undefined;
        alt?: string | undefined;
        autoComplete?: string | undefined;
        capture?: boolean | 'user' | 'environment' | undefined; // https://www.w3.org/TR/html-media-capture/#the-capture-attribute
        checked?: boolean | undefined;
        crossOrigin?: "anonymous" | "use-credentials" | "" | undefined;
        disabled?: boolean | undefined;
        enterKeyHint?: 'enter' | 'done' | 'go' | 'next' | 'previous' | 'search' | 'send' | undefined;
        form?: string | undefined;
        formAction?:
  등등...

이렇게 코드를 수정하니, 두 번째 에러 메시지도 사라졌다.

Type '{ onChange: (e: FormEvent) => void; ref: MutableRefObject | RefObject; type: string; value: string; onChangeValue: Dispatch<...>; }' is not assignable to type 'IntrinsicAttributes & { css?: Interpolation; } & InputBaseComponentProps & RefAttributes'.
Property 'type' does not exist on type 'IntrinsicAttributes & { css?: Interpolation; } & InputBaseComponentProps & RefAttributes'.

교훈

InputHTMLAttributes<T> => extends => 
HTMLAttributes<T> => extends => 
AriaAttributes, DOMAttributes<T> 

특정 HTML 요소는 최상위 타입인 AriaAttributes, DOMAttributes<T>부터 상속해오면서 속성 타입을 덮어씌우고 있었다!

그래서 결과는?

삽질 데스!

내가 만든 InputBaseComponent는 <input>그 이상도, 이하도 아니다.

베이스 컴포넌트는 어때야하는 것인가...? 머리가 아프다.

TextField 컴포넌트 만들기 - 2

에러가 해결된 TextField컴포넌트는 아래처럼 주어진 props를 활용하여 사용할 수 있다.

일반 <input>을 사용할 때보다 개선된 점이라면, onChange 이벤트핸들러 함수로 전달되는 콜백함수를 다 작성하는 대신에, setState 함수만 넘겨주면 내부에서 알아서 상태업데이트 콜백함수를 <input/>에 전달해준다는 점이 있겠다. MUI도 이런 방식을 사용하고 있으니 이 부분은 잘 구현한 것 아닐까?

export default function Home() {
  const [value, setValue] = useState('');
  return (
    <Main>
      <TextField value={value} onChangeValue={setValue} />
    </Main>
  );
}
  

Headless 컴포넌트?


일반적으로 작성하던 컴포넌트 방식은 기능 + 스타일 이 함께 들어있는 컴포넌트였다.

이미 컴포넌트가 스타일을 가지고 있기에 한 번 만들어 놓으면 그대로 가져다 쓸 수 있는 것이 장점이다.

그렇지만 스타일을 유연하게 변경할 수 없다는 단점도 있었다.

컴포넌트 설계 방식은 크게 Component UI , Headless

기본 함수형 컴포넌트 만들기


HeadlessUI에서 컴포넌트 설계전략 엿보기


https://headlessui.com/react/listbox

headless라는 단어는 데이터와

스타일은 가장 나중에 사용할 때 더하고, 기능만 구현하기!

배운 점

ref에 대해 톺아봤다.

리액트에서는 상태값을 컴포넌트가 쥐고 있으면서 조절하는 컴포넌트를 controlled 컴포넌트라고 하는데, input 도 value 값을 태그 바깥에서 관리하는 방식으로 작성할 수 있

리액트 공식문서 - Controlled vs Uncontrolled
이 과정에서 useRef에 대해 궁금해져 조사한 결과를 기록했습니다. ref는 기본 요소를 커스텀 컴포넌트로 만들 때 고려해야 하는 사항이라 생각해 학습을 진행했습니다.

"useRef 톺아보기"

참고


profile
안녕하세요 개발자 윤승록입니다. 내 성장을 가시적으로 기록하기 위해 블로그를 운영중입니다.

1개의 댓글

comment-user-thumbnail
2023년 8월 8일

오늘도 열일하셨네요✍️🫶

답글 달기