위 글은 프로젝트를 진행하다가 비슷한 UI가 반복된 문제를
합성 컴포넌트
패턴을 도입하여 해결한 일련의 이유와 과정 을 작성한 글입니다.
실제 프로젝트에서 구현한 DropDown 컴포넌트이다.
아래 코드는 DropDown 컴포넌트가 자주 쓰일 것 같아 공통 컴포넌트로 추상화한 코드이다.
( 이해를 돕기 위해 프로젝트에서 구현한 DropDown 컴포넌트를 codesandbox에서 비슷하게 구현했다. )
예시 코드
import React, { useReducer, useState, cloneElement } from "react";
type SelectedItem = {
id: string;
name: string;
selected: boolean;
action: (e?: MouseEvent) => void;
};
const selectedBy = (id: string) => ({
type: "SELECT_BY",
id,
});
type Action = ReturnType<typeof selectedBy>;
function reducer(state: SelectedItem[], action: Action): SelectedItem[] {
switch (action.type) {
case "SELECT":
return state.map((a) => {
return a.id === action.id
? { ...a, selected: true }
: { ...a, selected: false };
});
default:
return state;
}
}
interface DropDownProps {
selectedList: SelectedItem[];
trigger: React.ReactElement;
isOpen: boolean;
}
function DropDown({ selectedList, trigger, isOpen = false }: DropDownProps) {
const [isDropDownOpen, setIsDropDownOpen] = useState(isOpen);
const [dropdownList, dispatch] = useReducer(reducer, selectedList);
const toggle = () => {
setIsDropDownOpen(!isDropDownOpen);
};
return (
<div>
{cloneElement(trigger, { onClick: toggle })}
{isDropDownOpen && (
<div>
{dropdownList.map(({ id, name, selected, action }) => {
return (
<li
id={id}
onClick={() => {
dispatch(selectedBy(id));
action();
}}
>
{name}
</li>
);
})}
</div>
)}
</div>
);
}
export default DropDown;
나름 추상화를 잘 한 컴포넌트라고 생각했다. 그 이유는 다음과 같다.
- Trigger 컴포넌트를 외부로부터 주입, 어떤 Trigger 컴포넌트가 들어와도 DropDown 버튼으로 기능하게끔 동작하였다. (cloneElement를 활용하여)
- DropDown이 열렸을 때 보이는 리스트들을 클릭 시 각 항목에 맞는 이벤트들을 설정할 수 있게 하였다.
위의 그림에서 보는 것처럼 똑같은 DropDown 기능을 하는 컴포넌트를 만들어야 했다. 하지만 기존에 제가 만든 컴포넌트에서는 다음과 같은 변경 사항에 대처할 수 없었다.
- 기존의 DropDown 스타일은 버튼으로부터 왼쪽으로 튀어나와있었지만 새로운 요구사항에서는 오른쪽으로 튀어나오게 해야한다.
- 기존의
DropDownList
의 각 항목에 이미지, 이름, 부가 설명이 추가로 들어가야 한다.
주로 스타일과 관련된 요구사항에 대처를 하지 못하는 모습이다.
프로젝트 마감기한이 짧았기 때문에 일단 빠르게 대처하기 위해 노력했다. 팀 내에서 해결방안을 크게 두 가지를 제시했다.
공통 컴포넌트인 DropDown에 props를 추가, 분기점을 통해 UI 구분하기
공통 컴포넌트를 제거, 각각의 컴포넌트인 InfoDropDown, CelebDropDown을 만들어 구현하기
팀 내에서 1번 방식으로 구현할 경우 코드의 길이와 props가 길어져, 코드 수정 및 추가를 할 때 유지보수가 힘들어 비효율적이라고 판단, 2번
방식을 채택을 했다.
하지만 새로운 DropDown 컴포넌트를 만들 시 비슷한 로직을 또 작성을 해야한다. 뿐만 아니라 내가 아닌 다른 팀원들이 다른 DropDown 컴포넌트 작성 시 코드 통일성 역시 떨어진다는 단점이 있다. 앱이 커진다면 2번
방법 또한 굉장히 비효율적이라고 생각했다.
따라서 "다시 공통 컴포넌트를 만들어야겠다"라고 생각을 했다. 하지만 1번 방식의 문제점을 해결하고 싶었다.
컴포넌트 추상화에 대한 여러 아티클을 보고 공부한 결과 컴포넌트 추상화를 하는 나만의 기준을 정했다.
결론부터 말하자면 "요구사항
에 맞게 컴포넌트를 나누고 나눈 컴포넌트를 합성
하자"이다.
DropDown 기능 요구사항
1. DropDown Trigger 클릭 시 DropDownList를 열고 닫을 수 있다.
2. DropDownList의 각 항목 클릭 시 각 항목에 해당하는 이벤트를 실행할 수 있다.
3. DropDownList의 각 항목 클릭 시 어떤 항목을 클릭했는지 표시할 수 있다.
DropDown 스타일 요구사항
1. DropDownList 스타일은 버튼의 왼쪽과 오른쪽으로 튀어나올 수 있어야 한다.
2. 기존의DropDownList
의 각 항목에 이름 또는 이름, 이미지, 부가 설명이 추가로 들어가야 한다.
새로운 요구사항이 들어왔을 때 대부분 기존의 요구사항은 그대로 유지한 채 스타일 요구사항만 추가, 수정되는 것을 알 수 있다.
따라서 공통 컴포넌트를 만들 때 기능 요구사항을 모두 만족하도록 구현, 스타일은 컴포넌트 사용자에게 제어권을 넘겨주는 방식으로 구현하는 것이 좋아 보였다.
(스타일은 외부 props나 children을 통해서 컴포넌트 사용자에게 제어권을 넘겨주자)
React.children과 props 이해하기
react.children와 props를 통해서 컴포넌트의 기능과 스타일을 코드를 구현하는 사람이 아닌 컴포넌트 사용자가 이를 제어할 수 있다는 것을 이해해야한다.
버튼 컴포넌트를 예로 들어보자.
interface Props { children: React.ReactNode; } function Button({ children }: Props) { return <button>{children}</button> } // 사용처 <Button>hi</Button> <Button><img src="..." /></Button> <Button>111</Button>
위의 예제를 통해서 알 수 있듯이 children이라는 속성을 통해서 hi라는 버튼, 이미지 버튼, 111버튼 등 다양한 스타일을 만들 수 있다.
props 역시 다양한 스타일과 기능을 외부로부터 주입이 가능하다.
위의 방식과 유사하게 구현한 것이 바로 합성 컴포넌트 패턴
이다.
합성 컴포넌트 패턴
역시 기능 요구사항을 통해 각 컴포넌트를 분리하고 children과 props를 통해 스타일 제어권을 컴포넌트 사용자에게 주는 방식이다. 컴포넌트를 분리함으로써 기존 1번
방식에서 늘어나는 props
와 유지보수하기 힘들다는 문제점을 보완할 수 있다.
예를 들어보자.
먼저, 기능 요구사항을 분석, 각 요구사항을 만족하는 컴포넌트를 할당한다.
DropDown 기능 요구사항
1. DropDown Trigger 클릭 시 DropDownList를 열고 닫을 수 있다. (Trigger, DropDownList)
2. DropDownList의 각 항목 클릭 시 각 항목에 해당하는 이벤트를 실행할 수 있다. ( DropDownList, DropDownItem )
요구사항에 따라 Trigger
, DropDownList
, DropDownItem
으로 분리, 합성을 통해 DropDown 컴포넌트를 완성시키자.
Context API로 DropDown의 기능 나타내는 상태를 잘게 나눈 하위 컴포넌트로 상태를 공유하는 방식으로 구현할 수 있다. (합성 컴포넌트 구현에 관련한 글들은 구글링을 통해 쉽게 접할 수 있으니 넘어가도록 하자)
그렇다면 실제 사용예시를 한번 보자
<DropDown>
<DropDown.Trigger>Click me</DropDown.Trigger>
<DropDown.List>
<DropDown.Item>
<div>
<a href="www.celuveat.com">셀럽잇</a>
</div>
</DropDown.Item>
<DropDown.Item>Item 2</DropDown.Item>
<DropDown.Item>Item 3</DropDown.Item>
</DropDown.List>
</DropDown>
DropDown.Item를 한 번 자세히 보자.
children을 활용해 자식요소에 a 링크와 문자열 등 다양한 자식요소 스타일을 배치할 수 있다.
더 자세한 내용은 codesandbox에서 확인하자.
합성 컴포넌트를 통해서 다양한 스타일에 대처가 가능하다. 변경 유연한 컴포넌트 완성!!
기존에 만든 공통 컴포넌트는 기능에는 제한적이고 스타일 확장에 자유롭다. 하지만 태그는 div 태그로 고정되어야 한다는 단점 이 존재했다.
따라서 Polymorphic 컴포넌트를 통해 사용자가 직접 시멘틱 태그 를 지정할 수 있도록 구현하였다.
import { ElementType } from 'react';
import { Props } from '../../../../@types/props.type';
import { getCustomChildren } from '../../../../utils/getCustomChildren';
import { useDropDown } from '../../hooks/useDropDownContext';
import './style.css';
type OptionsProps = {
isCustom?: boolean;
};
const Options = <T extends ElementType = 'div'>({
as,
isCustom,
children,
...rest
}: Props<T, OptionsProps>) => {
const Element = as || 'div';
const { isOpen } = useDropDown();
if (isCustom) {
return isOpen
// 사용자가 스타일링한 자식요소의 컴포넌트에게 기능을 부여하는 로직
? getCustomChildren(children, {
...rest,
})
: null;
}
return isOpen ? (
<Element className="options" {...rest}>
{children}
</Element>
) : null;
};
export default Options;
위의 코드를 보면 'div'태그를 기본값으로 사용, 그렇지 않을 경우 as라는 Props를 통해 컴포넌트 사용자가 원하는 태그를 부여한다.
팀에서 공용 컴포넌트 사용 시 스타일링을 사용자가 직접 다 해야해서 불편하다는 피드백이 있었다. MUI나 chakra UI처럼 공용 컴포넌트 사용 시 스타일링까지 기대를 한 것 같다.
따라서 default 스타일을 제공, default 스타일에서 스타일을 수정하고 싶다면 isCustom 속성을 부여하여 유저가 직접 스타일링을 하는 컴포넌트를 구현하였다. ( 위의 보이는 함수인 getCustomChildren 함수를 통해 이를 구현하였다. )
import React, { ComponentPropsWithoutRef, ElementType } from 'react';
export const getCustomChildren = <
T extends ComponentPropsWithoutRef<ElementType>
>(
children: React.ReactNode,
actionProps: T
): React.ReactElement => {
const child = React.Children.only(children);
if (!React.isValidElement<T>(child))
throw Error('React Child is not a React Element');
return React.cloneElement(child, {
...actionProps,
});
};
쉽게 이야기 하면 cloneElement를 통해 사용자가 스타일링한 자식요소의 컴포넌트에게 기능을 부여하였다. 특정 컴포넌트 하나의 기능을 전수해주기 때문에 자식 요소의 개수를 제한하는 로직이 추가로 들어갔다.
예를 들어 DropDown의 트리거 버튼 클릭 시 단순히 DropDownList가 열고 닫히는 기능이 아닌 추가 기능 부여할 수 있다. (ex) api 요청, 그 외 다른 이벤트 기능들)
따라서 이를 해결하기 위해 externalOnClick이라는 함수를 props로 받는 방식으로 해결했다.
import { ComponentPropsWithoutRef, PropsWithChildren, useRef } from 'react';
import { getCustomChildren } from '../../../../utils/getCustomChildren';
import { useDropDown } from '../../hooks/useDropDownContext';
import useOnClickOutside from '../../hooks/useOnClickOutside';
import './style.css';
export interface TriggerProps extends ComponentPropsWithoutRef<'button'> {
isCustom?: boolean;
}
export const Trigger = ({
isCustom,
children,
onClick,
...rest
}: PropsWithChildren<TriggerProps>) => {
const { toggle, close } = useDropDown();
const triggerRef = useRef<HTMLButtonElement | null>(null);
useOnClickOutside<HTMLButtonElement>(triggerRef, close);
// 토글 기능 외의 추가 기능을 사용하고 싶다면 onClick을 직접 선언해주면 된다.
const externalClick = () => {
toggle();
if (!onClick) return;
onClick();
};
if (isCustom) {
return getCustomChildren(children, {
ref: triggerRef,
onClick: externalClick,
...rest,
});
}
return (
<button
ref={triggerRef}
className="trigger"
onClick={externalClick}
{...rest}
>
{children}
</button>
);
};
export default Trigger;
<DropDown>
<DropDown.Trigger onClick={() => {console.log(1)}}>trigger</DropDown.Trigger>
<DropDown.Options>
<DropDown.Option>DROP_DOWN_ITEMS</DropDown.Option>
<DropDown.Option>DROP_DOWN_ITEMS</DropDown.Option>
</DropDown.Options>
</DropDown>
구현한 코드 내용은 해당 링크에서 확인 가능하다.
이번 문제 해결을 통해서 추상화에 대한 정확한 정답은 없지만 컴포넌트 추상화에 대한 나만의 개념과 기준이 확고해졌다. (소프트웨어 설계 5원칙과 SOLID의 개념을 학습하고 이를 리액트에 녹여내려고 노력했다.)
하지만 공용 컴포넌트를 개발하면서 isValidElement
, cloneElement
등 리액트 레거시 코드들이 많이 사용하게 되었다. 이를 제거하고 좀 더 이해하기 쉬운 코드를 작성하는 것이 더 좋아보인다.