현재 리팩토링 있는 프로젝트에서 탭 컴포넌트
를 사용하고 있다. 탭 컴포넌트는 총 4군데에서 사용되고 있는데 이 탭 컴포넌트는 사용하는 모든 곳에서 각각 만들어주었다. (구현 방식은 제각각이긴 했다.)
디자인도 같은데, 이를 공통 컴포넌트로 만들어 사용하면 더 효율적일 거 같다는 생각이 들었다.
하지만 생각보다 간단한 문제는 아니었다. 공통 컴포넌트로 만든 과정을 작성했다.
처음으로 리액트
Context API
를 사용해보았는데, 전역 상태가 아닌 Context API가 이번 리팩토링에서 편리했던게 인상적이어서 그 부분에 대해서도 글을 작성해보았다.
import Tab from "./Tab";
interface CategoryTabProps {
currentTab: boolean;
setCurrentTab: React.Dispatch<React.SetStateAction<boolean>>;
}
const TabContainer = ({ currentTab, setCurrentTab }: CategoryTabProps) => {
return (
<section className="relative border-b flex gap-4">
<Tab
currentTab={currentTab}
tabStatus={false}
setCurrentTab={setCurrentTab}
/>
<Tab
currentTab={currentTab}
tabStatus={true}
setCurrentTab={setCurrentTab}
/>
<div
className={`absolute -z-0 pointer-events-none top-0 ${currentTab ? "left-32" : "left-0"} transition-all duration-200 pt-4 py-2 p-1 border-b border-green-200 h-full w-28`}
/>
</section>
);
};
export default TabContainer;
interface TabProps {
currentTab: boolean;
tabStatus: boolean;
setCurrentTab: React.Dispatch<React.SetStateAction<boolean>>;
}
const Tab = ({ currentTab, tabStatus, setCurrentTab }: TabProps) => {
return (
<>
<button
className={`w-28 h-full ${currentTab === tabStatus
? "text-green-600 text-semibold-r"
: "text-semibold-r text-gray-400"
}`}
onClick={() => setCurrentTab(tabStatus)}
>
<span className={`block py-2 ${currentTab === tabStatus ? " " : "border-transparent"}`}>
{tabStatus ? "진행 중인 채널" : "대기 중인 채널"}
</span>
</button>
</>
);
};
export default Tab;
이런 구조는 무엇이 잘못된걸까?
이런 구조는 사용하기 불편하고 확장성도 없다고 생각을 했다. 탭을 만약 여러 개를 만든다면 분명 이 구조는 그대로 사용할 수 없었고, 탭 컴포넌트 안에서 탭에 대한 이름을 넣어주기 때문에 다른 페이지에서는 이용이 불가능했다. 즉, 재사용할 수 없었고 탭을 추가할 수 없었다. 또한 탭에 해당하는 내용을 보여주는 부분이 없이 그냥 currentTab이 무엇인지에 따라 다른 컴포넌트에서 내용을 관리해주고 있었다. 사용하는 입장에서도 탭 컴포넌트를 어떻게 만들었는지 제대로 이해하고 써야 수정할 수 있는 구조였다.
일단 위 탭 컴포넌트
의 문제를 정리하면 다음과 같다.
탭도 사용하는 컨텍스트에 따라 조금씩 달랐는데 다른 곳에서 사용하는 탭의 경우에는 선택된 탭을 표시하는 밑줄이 탭이 3개부터 추가되면 위치가 이상했다. (2개일 때만 고려해서 위치 계산이 잘못되어 있었던 것이다.)
코드를 작성하기 편하게 설계하려면 가장 중요한 건 내부 구조를 몰라도 사용할 수 있어야한다는 생각이 들었다. 탭 이름만 전달해도 탭이 만들어지고 그 내부가 어떻게 구성되던 탭을 쉽게 사용할 수 있는 컴포넌트를 만들어야겠다 생각했다.
사용 방법을 보고 쉽게 가져다 쓸 수 있는 컴포넌트
를 만들자.TabProvider 안에 TabList와 TabPanel을 넣어주고, TabList 안에는 탭에 보여질 탭들을 추가해준다.
최종적으로 완성된 탭 컴포넌트 코드는 위와 같다.
위와 같이 구성되게 했고, 탭 컴포넌트를 사용하는 영역에서 개발자는 위 구성요소를 이용해 원하는 탭을 쉽게 추가해서 id만 넣어주고, 탭에 보여질 내용 컴포넌트만 만들어서 넣어주면 된다. 사용자는 탭의 상태 관리나 스타일 변경에 대해 신경 쓸 필요가 없다.
탭 컴포넌트르 어떻게하면 쉽게 가져다 쓸 수 있게 할 수 있을까?에 대한 고민이 많았다. 그러다 알게된 패턴이
컴포지션 패턴(Compound Component Pattern)
이었다. 복잡한 UI를 직관적으로 사용할 수 있게 해주는 패턴이라고 해서 이에 대해서도 공부해보았다.
여러 개의 작은 컴포넌트들이 서로 결합하여 하나의 복합적 기능을 제공하는 방식이다. 각 컴포넌트는 독립적인 역할을 하지만, Context API와 같은 상태 공유를 통해 통신할 수 있다.
전에 React Bootstrap의 탭 컴포넌트를 사용한 적이 있었는데, 이를 보고 유사하게 만들면 사용하기 편하겠다는 생각이 들었다. 이를 컴포지션 패턴으로 구현해서 사용하기 쉬운 컴포넌트를 구성할 수 있었다. 컴포지션 패턴은 자주 들어봤었고 사용하게 되면 편할까?를 전체 리팩토링 시 적용하며 느껴보고 싶었는데 탭 컴포넌트에 적용해보며 패턴을 이해할 수 있었다.
export const TabContext = createContext<TabContextType | null>(null);
export const useTabContext = () => {
const context = useContext(TabContext);
if (!context) {
throw new Error("useTabContext는 TabProvider 내부에서 사용되어야 합니다.");
}
return context;
}
const TabProvider = ({
children,
defaultTab,
className = ""
}: ProviderProps) => {
const [activeTab, setActiveTab] = useState(defaultTab);
const contextValue = {
activeTab,
setActiveTab
};
return (
<TabContext.Provider value={contextValue}>
<div className={className}>
{children}
</div>
</TabContext.Provider>
)
}
const TabList = ({ children }: ListProps) => {
const { activeTab } = useTabContext();
const listRef = useRef<HTMLDivElement>(null);
const [indicatorStyle, setIndicatorStyle] = useState({ left: 0, width: 0 });
useEffect(() => {
if (!listRef.current) return;
const tabRefs = Array.from(listRef.current.children)
.filter(child => child.getAttribute("role") === "tab");
const activeTabElement = tabRefs.find(
tab => tab.getAttribute("data-tab-id") === activeTab
) as HTMLElement;
if (activeTabElement) {
setIndicatorStyle({
left: activeTabElement.offsetLeft,
width: activeTabElement.offsetWidth
});
}
}, [activeTab]);
return (
<div className="relative flex gap-6 my-4 ml-2 pb-2" ref={listRef} >
{children}
<div
className="absolute -z-0 pointer-events-none top-0 transition-all duration-200 pt-4 py-2 p-1 border-b-2 border-green-300 h-full"
style={{
left: `${indicatorStyle.left - 4}px`,
width: `${indicatorStyle.width + 8}px`
}
}
/>
</div >
)
}
const TabItem = ({ id, children }: ItemProps) => {
const { activeTab, setActiveTab } = useTabContext();
const isActive = activeTab === id;
return (
<button
role="tab"
data-tab-id={id}
aria-selected={isActive}
className={`flex flex-row gap-1
${isActive
? "text-bold-s text-green-500"
: "text-medium-l text-gray-400"
}`}
onClick={() => setActiveTab(id)}
>
<span className="px-1 pb-1">
{children}
</span>
</button>
)
}
const TabListContent = ({
children,
className
}: ListContentProps) => {
return (
<div className={className}>
{children}
</div>
)
}
const TabPanel = ({
id,
children,
className = ""
}: PanelProps) => {
const { activeTab } = useTabContext();
const isActive = activeTab === id;
if (!isActive) return null;
return (
<div role="tabpanel" className={className}>
{children}
</div>
)
}
프로젝트에서 전역 상태 라이브러리인 Zustand
를 사용하고 있었다. 그런데 탭 컴포넌트를 리팩토링할 때 props drilling의 불편함을 전역 상태가 아닌 Context API를 통해 해결했다.
Context API
를 이용해 현재 activeTab 즉, 현재 선택된 탭 상태를 관리했다.
const TabProvider = ({ ... }: ProviderProps) => {
const [activeTab, setActiveTab] = useState(defaultTab);
const contextValue = {
activeTab,
setActiveTab
};
return (
<TabContext.Provider value={contextValue}>
<div className={className}>
{children}
</div>
</TabContext.Provider>
)
}
Context API
는 TabProvider 인스턴스가 자체 상태를 가질 수 있게 해주고, 리액트가 생명주기를 관리하기 때문에 신경 쓸 필요가 없다. 또한 복잡한 스토어를 생성할 필요 없이 한 번 컴포넌트로 만들어두면, 이를 탭을 사용하려는 컨텍스트에서 구조에 맞게 작성만 해주면 상태 관리를 신경 쓸 필요가 없는 것이다.
Zustand
로도 독립된 인스턴스를 구현할 수 있다. 그러나 동적으로 스토어를 생성해야한다.
import { create } from "zustand";
import { nanoid } from "nanoid"; // 고유 id 생성을 위한 라이브러리
interface TabStore {
activeTab: string;
setActiveTab: (id: string) => void;
}
// 전역 스토어 인스턴스 맵
const storeInstances = new Map<string, ReturnType<typeof create<TabStore>>>();
// 새로운 탭 스토어 생성 함수
export const createTabStore = (defaultTab = "") => {
const storeId = nanoid(); // 고유 id 생성
// 새 스토어 인스턴스 생성
const useStore = create<TabStore>((set) => ({
activeTab: defaultTab,
setActiveTab: (id: string) => set({ activeTab: id })
}));
// 스토어 인스턴스 맵에 저장
storeInstances.set(storeId, useStore);
return {
useStore,
storeId,
cleanup: () => storeInstances.delete(storeId)
}
}
// 특정 스토어 인스턴스 가져오기
export const getTabStore = (storeId: string) => {
return storeInstances.get(storeId);
}
storeInstances
에 탭을 사용하는 컴포넌트에서 호출하는 탭 스토어 인스턴스를 저장해서 관리해준다.const storeRef = useRef(createTabStore(defaultTab));
을 호출한다.이렇게 스토어를 만들고 더 이상 구현하지 않았다.
Zustand
를 사용하지 않아야 생각했던 건 목적에 대해 더 생각하보니 명확해졌다. 탭 컴포넌트는 독립된 상태를 가져야했는데, Zustand는 전역 상태를 위해 설계되었는데 이를 이용해 지역 상태처럼 사용하려하는 방식이었다. 그렇다면 굳이 전역 상태 라이브러리로 지역 상태를 복잡하게 구현하려는 노력이 의미있을까?하는 생각이 들었다.