최근 토스ㅣSLASH 22 - Effective Component 지속 가능한 성장과 컴포넌트 컴퍼런스를 통해 변경에 유연한 컴포넌트를 설계하는 법에 대해 배우게 되었다.
영상을 참고하여 내 나름대로 Select 컴포넌트를 만드는데 성공했다.
해당 포스팅에서 변경에 유연한 컴포넌트를 어떻게 설계하고 이것을 토대로 어떻게 완성하는지에 대한 고민들과 결과물을 정리하고자 한다.
이전 프로젝트에서 Select 컴포넌트를 만들었던 방식은 다음과 같았다.
const Select = ({ defaultValue, ...rest }: Props) => {
const [isOpen, setIsOpen] = useState();
const [selected, setSelect] = useState(defaultValue);
const close = () => setIsOpen(false);
const open = () => setIsOpen(true);
return (
<Styled.Select {...rest}>
<label>
<span>{defaultValue}</span>
<ArrowIcon />
</label>
{isOpen ? (
<Options onClick={close}>
{options.map(value => {
return (
<List
selected={selected === value}
onClick={() => setSelect(value)}
>
{value}
</List>
)
})}
</Options>
): null}
</Styled.Select>
);
};
해당 Select 컴포넌트는 변경이 발생했을 때 제대로 대응하지 못한다. 그렇기 때문에 여러곳에서 재사용하기도 어렵다.
예를 들어보자
현재 Select에는 label 컴포넌트가 있고 해당 컴포넌트로 선택된 값을 보여주고 있다. 디자이너가 label 대신 다른 ui로 Select를 만든다면 어떻게 해야할까?? 또한 선택된 값들을 Select 내부 뿐만아니라 다른 컴포넌트에서도 공유해야한다면 어떻게 해야 할까??
당장 이 부분만 봐도 변경에 유연하지 못하다는 것을 알 수 있다.
그렇다면 어떻게 개선하면 변경에 유연한 컴포넌트가 될까?
먼저 Select 컴포넌트에서 다루고 있는 데이터를 기준으로, 담당하고 있는 역할을 기준으로 분리해보자.
우선 Menu의 노출 여부를 제어하는 내부 상태 isOpen을 분리할 수 있다. 그리고 이 상태를 Dropdown이라는 컴포넌트로 관리할 수 있다.
그리고 이 상태를 바꾸기 위한 상호 작용도 따로 관리할 수 있다. 이 부분을 Dropdown.Trigger로 구성할 것이다.
또 Option영역도 분리해줄 수 있다. 열고 닫힌 상태에 따라 노출 여부가 결정되기 때문에 Dropdwon.Menu로 구성할 것이다.
마지막으로 메뉴를 구성하는 각각의 item을 별도로 분리하여 상호작용을 담당하도록 구성하였다.
완성된 모습은 다음과 같다.
const Select = ({value, onChange, Trigger, options }: Props) => {
return (
<Dropdown value={value} onChange={onChange}>
<Dropdown.Trigger>{Trigger}</Dropdown.Trigger>
<Dropdown.Menu>
{options.map((option, index) => (
<Dropdown.Item key={index} value={option} />
))}
</Dropdown.Menu>
</Dropdown>
);
};
Dropdown은 내부적으로 Menu가 보일지 말지 결정한다. 이것은 Trigger와 연결되어 진다.
Item에서 발생하는 onClick 이벤트는 Dropdown의 onChange이벤트로 이어지게 된다.
이 컴포넌트를 사용하면 다음과 같을 것이다.
const UseSelect = () => {
const {selected,change} = useState("선택");
return (
<Select
Trigger={
<label>
<span>{selected}</span>
<ArrowIcon />
</label>
}
value="선택"
onChange={change}
options={options}
/>
)
}
Select 컴포넌트와 Trigger로 전달은 label은 서로의 존재에 대해 알지 못한다.
즉 서로의 변경이 서로에게 영향을 끼치지 않게 된 것이다. 이것으로 각각의 컴포넌트가 변경에 유연하도록 만들 수 있다는 이야기이다. Trigger부분이 변경 되더라도 Select 컴포넌트 자체는 아무런 변경이 없기 때문이다.
그럼 해당 컴포넌트를 어떻게 만들지 알아보자
이제 앞서 설명한 구조를 코드로 하나하나 설명하고자 한다.
Dropdown에서의 상태를 관리하기 위해 Context api를 이용하고자 한다.
interface DropdownContextValue {
isOpen: boolean;
select: string;
handleOpen: () => void;
handleClose: () => void;
handleSelctAndClose: (item: string) => void;
}
const DropdownContext = createContext<DropdownContextValue | null>(null);
interface Props {
value: string;
onChange: React.Dispatch<React.SetStateAction<string>>;
}
export function Dropdown({
value,
children,
onChange,
}: PropsWithChildren<Props>) {
const [isOpen, setIsOpen] = useState(false);
const [select, setSelect] = useState(value);
const firstMounded = useRef(true);
useEffect(() => {
if (!firstMounded.current) {
onChange(select);
}
firstMounded.current = false;
}, [select]);
const handleOpen = () => {
setIsOpen(true);
};
const handleClose = (item?: string) => {
setIsOpen(false);
};
const handleSelctAndClose = (item: string) => {
handleClose();
setSelect(item);
};
return (
<DropdownContext.Provider
value={{ isOpen, select, handleOpen, handleClose, handleSelctAndClose }}
>
{children}
</DropdownContext.Provider>
);
}
Dropdown.Trigger = Trigger;
Dropdown.Menu = Menu;
Dropdown.Item = Item;
export const useDropdownContext = () => {
const context = useContext(DropdownContext);
if (context === null) {
throw new Error(
"useDropdownContext must be used within a DropdownProvider"
);
}
return context;
};
Dropdown context에서는 위의 코드와 같이 isOpen, select, handleOpen, handleClose, handleSelctAndClose을 value로 넣어준다.
select
isOpen
handleOpen
handleClose
handleSelctAndClose을
const Trigger = ({ children }: PropsWithChildren<Props>) => {
const context = useDropdownContext();
const { isOpen, handleOpen, handleClose } = context;
return <div onClick={!isOpen ? handleOpen : handleClose}>{children}</div>;
};
export default Trigger;
Menu를 노출할지 말지의 상태를 Trigger에서 정의해 준다. Trigger부분은 변경가능 할 수 있도록 children을 통해 Trigger를 받아오게 해주었다.
const Menu = ({ children }: PropsWithChildren<Props>) => {
const { isOpen } = useDropdownContext();
if (!isOpen) return null;
return (
<Styled.MenuWrapper>
<Styled.Lists>{children}</Styled.Lists>
</Styled.MenuWrapper>
);
};
export default Menu;
열고 닫힌 상태에 따라 노출 여부를 결정 짓는다.
const Item = ({ value }: Props) => {
const { handleSelctAndClose, select } = useDropdownContext();
return (
<Styled.ItemWrapper
isSelect={select === value}
onClick={() => handleSelctAndClose(value)}
>
{value}
</Styled.ItemWrapper>
);
};
export default Item;
현재 선택된 값인지 아닌지를 select === value로 판단해주고 해당 Item을 클릭하면 handleSelctAndClose함수의 인자에 클릭한 값을 인자로 넣어 호출해준다.
토스 컴퍼런스를 처음 보던 당시 이해가 가지 않았던 코드를 직접 부딪히며 개발을 하다보니 어떻게든 만들어내서 정말 기쁘다.
처음 볼때는 변경에 유연한 컴포넌트에 대해 잘 이해가 가지 않았지만 Select 컴포넌트를 만들면서 컴포넌트를 어떻게 설계를 해야 변경에 유연하게 만들 수 있을지 조금은 알게 된 것 같다.
토스ㅣSLASH 22 - Effective Component 지속 가능한 성장과 컴포넌트
React-전역상태관리를-통해-모달을-띄우는-게시물을-만들어보자
5 Advanced React Patterns