Intro
기획팀 요청으로 선택 버튼이 있는 인풋을 구현한 적이 있다.
요청 사항
- 인풋은 유저가 직접 값을 입력할 수 없음
- 인풋의 값은 선택 버튼 클릭 후 등장하는 드롭다운의 옵션 중 선택
고려 사항
흰색 박스가 input처럼 보이지만, 실제 input 태그는 하늘색으로 표시한 영역이고
흰색 박스는 label과 input을 감싸는 div 태그이다.
공통 컴포넌트 Label, Input, Button을 조합해서 아래처럼 만들었다.
import styled from "styled-components";
import Label from "./components/Label/Label";
import Input from "./components/Input/Input";
import Button from "./components/Button/Button";
<Form>
<FormTitle>새로운 캐릭터 생성하기</FormTitle>
<InputList>
<InputWrap>
<InputContents labelWidth={80}>
<Label>직업</Label>
<Input readOnly value={inputValue} />
</InputContents>
<Button onClick={() => setShowModal((prev) => !prev)}>
<BtnName>선택</BtnName>
</Button>
</InputWrap>
<InputWrap>
<InputContents labelWidth={80}>
<Label>필살기</Label>
<Input readOnly value={inputValue} />
</InputContents>
<Button onClick={() => setShowDropdown((prev) => !prev)}>
<BtnName>선택</BtnName>
</Button>
</InputWrap>
</InputList>
<Button onClick={() => {}}>
<CreateBtnName>생성</CreateBtnName>
</Button>
</Form>
선택 버튼이 있는 인풋이 자주 사용될 것 같아서
SelectInput이라는 공통 컴포넌트로 만들면 좋을 것 같았다.
당시에는 코드의 양이 줄었다며 좋아했었는데,
이것은 고통의 서막이었다..
<SelectInput label="직업" otherProps.. />
<SelectInput label="필살기" otherProps.. />
2차 요청
이번에는 조금 다른 UI의 인풋을 만들어달라는 요청이 왔다.
문제
기존에 만들어 둔 SelectInput을 재사용하기 어려웠다.
그대로 사용하자니 prop이 점점 늘어나고,
컴포넌트 내부에 조건부 렌더링 코드가 많아져 복잡해질 것 같았다.
실제 업무에서도 SelectInput을 그대로 활용하기가 어려워서
팀원들이 UI가 조금 다른 인풋을 각자 만들어서 쓰곤 했다.
공통으로 못 쓰는 공통 컴포넌트라니..!
재사용성을 높이는 방법에 대해 고민하던 중,
카카오엔터테인먼트 기술 블로그에서
[합성 컴포넌트로 재사용성 극대화하기]라는 글을 읽게 되었다.
리액트 디자인 패턴 - 합성 컴포넌트
기술 블로그에 따르면, 합성 컴포넌트 패턴은
하나의 컴포넌트를 여러 가지 집합체로 분리한 뒤
분리된 각 컴포넌트를 사용하는 쪽에서 조합해 사용하는 컴포넌트 패턴을 의미한다.
기존
원래는 필요한 요소들을 props로 넘겨주었다면
<Dialog
dimmed
title="타이틀"
checkBoxList={[
{
title: '버튼명',
isChecked: true,
hasArrowButton: true,
},
{
title: '버튼명',
isChecked: false,
hasArrowButton: true,
},
{
title: '버튼명',
isChecked: false,
hasArrowButton: true,
},
{
title: '버튼명',
isChecked: false,
hasArrowButton: true,
},
{
title: '버튼명',
isChecked: false,
hasArrowButton: true,
},
]}
labelButtonList={[
{
title: '버튼레이블',
}
]}
/>
합성 컴포넌트
요구사항이 추가된다 하더라도 서브 컴포넌트만 중간에 끼워 넣으면 바로 대응할 수 있다.
위 코드보다 더 직관적이고 상황별로 유연하게 대처할 수 있어 좋다고 느꼈다.
<Dialog>
<Dialog.Dimmed />
<Dialog.Title>타이틀</Dialog.Title>
<Dialog.CheckBox isChecked hasArrowButton>
버튼명
</Dialog.CheckBox>
<Dialog.CheckBox hasArrowButton>버튼명</Dialog.CheckBox>
<Dialog.CheckBox hasArrowButton>버튼명</Dialog.CheckBox>
{/* 혹시 여기에 무언가 설명이 들어가야 한다면 아래처럼 추가만 하면 됩니다. 더이상 이미 구현된 Dialog를 수정할 필요는 없습니다.
<Dialog.Description>설명</Dialog.Description>
*/}
<Dialog.CheckBox hasArrowButton>버튼명</Dialog.CheckBox>
<Dialog.CheckBox hasArrowButton>버튼명</Dialog.CheckBox>
<Dialog.LabelButton>버튼레이블</Dialog.LabelButton>
</Dialog>
사실 나도 Label, Input, Button을 조합해서 사용했었지만
더 세세하게 분리할 필요가 있었던 것이다.
바로 리팩토링을 해보았다!
리팩토링
1. 영역 나누기
영역은 아래와 같이 3개로 나눴다.
토스 FE 개발자 한재엽님의 글 [변경에 유연한 컴포넌트]에서
right 라는 이름으로 역할을 표현할 수 있다고 해서 응용해 보았다.
Left : 인풋 왼쪽 (ex. 외부 label)
Center : 흰색 박스 영역 (ex. 내부 label, input, input 값 길이 등)
Right: 인풋 오른쪽 (ex. button)
2. 조합
서브 컴포넌트를 만들어주고, Input.tsx에서 조합해 준다
import InputLeft from "./components/InputLeft";
import InputCenter from "./components/InputCenter";
import InputRight from "./components/InputRight";
import InputLabel from "./components/InputLabel";
import InputField from "./components/InputField";
import InputButton from "./components/InputButton";
import InputLayout from "./components/InputLayout";
const Input = Object.assign(InputLayout, {
Left: InputLeft,
Center: InputCenter,
Right: InputRight,
Label: InputLabel,
Field: InputField,
Button: InputButton,
});
export default Input;
InputButton, InputLabel, InputField는
기존에 만들었던 공통 컴포넌트를 import 해서 만들었다.
interface InputButtonProps {
children?: ReactNode;
onClick: () => void;
}
function InputButton({children, onClick}: InputButtonProps) {
return (
<Button onClick={onClick}>{children}</Button>
)
}
interface InputFieldProps {
max?: number;
readOnly?: boolean;
value: string;
onChange?: ChangeEventHandler<HTMLInputElement>;
}
function InputField (props : InputFieldProps) {
return (
<Input {...props} />
);
}
interface InputLabelProps {
children?: ReactNode;
width?: number;
}
function InputLabel({children, width}: InputLabelProps) {
return (
<Wrapper width={width}>
<Label>{children}</Label>
</Wrapper>
)
}
3. children 넣기
각 서브컴포넌트에 children를 넣어준다.
import Input from "./components/Input/Input";
<Input>
<Input.Center>
<Input.Label width={80}>유형1</Input.Label>
<Input.Field readOnly value={inputValue} />
</Input.Center>
<Input.Right>
<BtnWrap>
<Input.Button onClick={() => setShowDropdown(prev => !prev)}>선택</Input.Button>
</BtnWrap>
</Input.Right>
</Input>
완성
유형 1, 2, 3을 각각 만들어보았다.
UI가 조금 달라도 필요한 요소만 조합해서 쓰니 재사용이 쉬웠다.
아직 스타일 커스텀은 고려하지 않았기 때문에, 스타일 커스텀 방법까지 찾아볼 예정이다.
<InputList>
<Input>
<Input.Center>
<Input.Label width={80}>유형1</Input.Label>
<Input.Field readOnly value={inputValue} />
</Input.Center>
<Input.Right>
<BtnWrap>
<Input.Button onClick={() => setShowDropdown(prev => !prev)}>선택</Input.Button>
</BtnWrap>
</Input.Right>
</Input>
<Input>
<Input.Center>
<Input.Label width={60}>유형2</Input.Label>
<InputFieldWrap>
<Input.Field readOnly value={inputValue} />
<InputLength>1/20</InputLength>
</InputFieldWrap>
</Input.Center>
<Input.Right>
<BtnWrap>
<Input.Button onClick={() => setShowDropdown(prev => !prev)}>선택</Input.Button>
</BtnWrap>
</Input.Right>
</Input>
<Input>
<Input.Left>
<Input.Label width={60}>유형3</Input.Label>
</Input.Left>
<Input.Center>
<Input.Field readOnly value={inputValue} />
</Input.Center>
<Input.Right>
<BtnWrap>
<Input.Button onClick={() => setShowDropdown(prev => !prev)}>선택</Input.Button>
</BtnWrap>
</Input.Right>
</Input>
</InputList>
참고 자료
- 토스 FE 개발자 한재엽님 : [변경에 유연한 컴포넌트]
- 카카오 엔터테인먼트 기술 블로그 : [합성 컴포넌트로 재사용성 극대화하기]
'프론트엔드 > React' 카테고리의 다른 글
[react-query] 단일 진실 공급원을 고려하며 수정 form 만들기 (0) | 2023.01.18 |
---|
댓글