지난 프로젝트에서 Form 컴포넌트를 많이 사용했었는데, 그 때 이러한 고민이 있었다.
당시 위 고민들을 해결하지 못하고 약 400줄짜리 코드를 작성하게 되었다. 현 프로젝트 디자인에 form이 꽤 사용되는 것을 보고 이번에는 꼭 위 문제들을 개선해야겠다고 생각하게 되었다.
첫번째 문제의 해결책으로 떠올리게 된 방법은 Compound Component
를 사용하는 것이었다.
Compound Component는 로직과 UI를 표현적으로 분리하면서 부모 컴포넌트가 자식 컴포넌트와 의사 소통할 수 있는 표현적이고 유연한 방법을 제공하는 React 패턴입니다.
컴파운드 컴포넌트는 쉽게 말해 여러 개의 자식 컴포넌트가 부모 컴포넌트의 데이터를 공유받아 사용할 수 있는 컴포넌트이다. 이 개념으로 다음과 같이 적용해보려고 했다.
자식 컴포넌트로 라벨, input, helper text, button을 모두 따로 만들고, 이들을 감싸는 부모 컴포넌트는 context API를 사용하여 하위에 있는 컴포넌트들에게 상태를 내어줄 수 있도록 한다.
input
: input에서는 입력이 일어날 때마다 validation을 진행하여 잘못된 입력인지 판단한다. 만약 validation을 통과하지 못했다면 부모 컴포넌트의 상태를 업데이트하여 오류에 대한 정보를 추가한다.helper text
: 부모 컴포넌트로부터 오류 정보를 읽고 해당하는 것이 있다면 텍스트를 렌더링한다.button
: 마찬가지로 부모 컴포넌트로부터 오류 정보를 읽고 하나라도 존재한다면 disabled 처리한다.위를 계획한 후 신나게 코드를 작성해 보았는데...
개고생했다. 완성하는 데에 3일이 걸렸다.
export const FormContext = createContext<FormContextInterface | undefined>(
undefined,
);
export const useFormContext = () => {
const context = useContext(FormContext);
if (!context) {
throw new Error('useFormContext must be used within a FormProvider');
}
return context;
};
Compound Pattern에서는 보통 부모의 상태를 공유하기 위해 Context API를 사용한다. FormContextInterface
에는 자식 컴포넌트들에게 공유할 상태에 대한 정보를 적는다.
❓단순히 props drilling을 피하기 위해서라면 전역 상태 관리 도구들도 있는데 왜 Context API를 사용할까?
: (GPT로 들은 응답) 많은 경우 이러한 컴포넌트와 관련된 상태는 상당히 지역적이며 전체 애플리케이션 전역에서 공유할 필요가 없습니다. Context API를 사용하면 상태를 더 지역적으로 관리할 수 있어 전역 상태 관리 도구의 필요성을 줄일 수 있습니다.
const Form = ({ children }: FormProps) => {
return (
<FormContext.Provider
value={자식 컴포넌트들과 공유할 상태 넣기}
>
<form>
{children}
</form>
</FormContext.Provider>
);
};
이제 부모 컴포넌트도 만들어준다. Context API의 Provider를 사용하여 form 태그를 감싸고 props로 원하는 상태를 넣어준다. 이 컴포넌트의 하위 컴포넌트들은 해당 상태를 hook을 통해 쉽게 접근할 수 있게 된다.
단순한 Helper Text를 예시로 만들어보자.
const FormHelperText = ({ name }: FormHelperTextProps) => {
const { formState } = useFormContext();
const { errors } = formState;
const isError = !!errors[name]?.message;
return (
<span
className={`${
isError ? 'text-Error' : 'text-transparent'
} typography-Body2 typography-SB`}
>
{isError ? String(errors[name]?.message) : 'no error'}
</span>
);
};
form 태그에서 사용하는 name을 사용하여 어떤 form인지를 구분할 수 있게 하는 데에 사용한다. 해당 form을 context에 저장하는 데이터에도 동일하게 사용하여 에러가 발생했을 때 어떤 input에서 발생하는 에러인지 구분할 수 있다. 이를 위해 helper text를 비롯한 자식 컴포넌트들에서는 name을 props로 받았다.
실제로 에러 메시지에 접근하기 위해서는 useFormContext
hook을 통해 context에 저장하고 있는 데이터를 받아온다. 여기서 error를 파싱하여 name에 해당하는 error가 있는지 판단하여 그 결과에 따라 에러 메시지를 보인다.
{isError ? String(errors[name]?.message) : 'no error'}
대부분의 코드는 &&
연산자를 사용하여 에러 메시지가 있을 때에만 helper text가 렌더링 되도록 한다. 하지만, form text 밑에 다른 컴포넌트가 위치하게 된다면 에러 메시지의 존재 여부에 따라 높이가 생기거나 말기 때문에 validation 결과에 따라 마치 아래에 있는 컴포넌트가 움직이는 듯한 착각을 준다. 이를 방지하기 위해 helper text의 영역을 보장할 수 있는 방법을 고민하였고, 무조건 에러 메시지가 존재하나 error가 없다면 색상을 transparent로 바꾸는 방법을 통해 해결하였다.
각 컴포넌트를 다 만들었다면 이제 사용해보자.
<Form onSubmit={onSubmit}>
<Form.Label label="유저" />
<Form.Input name="USER" type="text" />
<Form.HelperText name="USER" />
<Form.Label label="계좌번호" />
<Form.Input name="ACCOUNT" type="number" />
<Form.HelperText name="ACCOUNT" />
// 꼭 label, pointInput, helpertext의 조합으로 사용할 필요가 없다.
<Form.Label label="포인트" />
<Form.Input name="POINT" />
<Form.Input name="BANK" />
<Form.HelperText name="BANK" />
...
<Form.Button>Submit</Form.Button>
</Form>
이제 개발자는 Form 요소에서 원하는 부분만 조합하여 자유롭게 사용할 수 있다.
지금까지 내가 이론적으로 생각한 Form 컴포넌트에 Compound Component 패턴을 적용하는 방법을 살펴보았다. 다음 포스팅에서는 구현하는 과정에서 어려웠던 점을 살펴보도록 하겠다.
input 입력 전 | input 입력 후 | input 입력 후 - comma 넣기 | validation 통과하지 못한 경우 |
---|---|---|---|
토스
에서 송금할 때 확인할 수 있는 view들을 캡쳐해보았다.