React Tree를 만들게 된 배경
JSON 데이터를 트리 구조로 표현해야 하는 요구사항이 있었다. 단순히 계층 구조를 화면에 보여주는 것뿐만 아니라, 각 노드의 스타일을 변경하거나 컴포넌트를 확장할 수 있어야 했고, 동시에 JSON 데이터만 주면 트리 형태로 쉽게 렌더링할 수 있어야 했다.
그러나 기존 오픈소스 라이브러리들은 스타일이나 컴포넌트를 유연하게 커스터마이징하기 어렵거나, JSON 데이터를 기반으로 선언적으로 트리를 구성하기에 적합하지 않았다. 이러한 이유로 직접 트리 컴포넌트를 구현하게 되었다.
Fluent UI React Tree 발견
검색 과정에서 Microsoft의 Fluent UI에서 제공하는 @fluentui/react-tree 패키지를 발견하였다. Fluent UI의 트리 컴포넌트는 디자인이 깔끔하고 선언적으로 트리를 구성할 수 있는 API를 제공한다는 점에서 매력적이었다. 예를 들어 다음과 같은 방식으로 트리를 선언할 수 있다.
<Tree aria-label="File Explorer">
<TreeItem>
<TreeItemLayout>Documents</TreeItemLayout>
<Tree>
<TreeItem>
<TreeItemLayout>Meeting notes</TreeItemLayout>
</TreeItem>
</Tree>
</TreeItem>
</Tree>
TypeScript
복사
위와 같이 React 컴포넌트만으로 계층 구조를 표현할 수 있는 점은 매우 직관적이었다.
Fluent UI를 채택하지 않은 이유
그러나 Fluent UI 전체를 프로젝트에 도입하지는 않았다. 가장 큰 이유는 두 가지였다.
•
전체 Fluent UI 라이브러리가 아니라 트리 컴포넌트만 필요했음
•
현재 서비스에서는 Tailwind CSS를 사용하고 있었고, Fluent UI는 스타일링에 Griffel이라는 CSS-in-JS 솔루션을 사용. 두 스타일 시스템을 동시에 유지하게 되면 관리 복잡도가 커지고 번들 크기 또한 증가하게 됨.
•
json 데이터를 넣으면 바로 tree로 만들어주는 구조는 아님.
따라서 Fluent UI react-tree의 아이디어와 구조는 차용하되, Tailwind 기반 환경에서도 부담 없이 사용할 수 있는 독립적인 트리 컴포넌트를 만들기로 결정했다.
Fluent UI React Tree 코드 분석
Fluent UI의 트리 구현을 살펴보면 몇 가지 핵심 아이디어가 있다.
•
Context 기반 상태 관리: 트리 전체의 상태(select, expand, depth 등)를 Context로 공유한다.
•
Tree / TreeItem / TreeItemLayout 컴포넌트 분리: 각 역할을 명확히 나누어 선언적 API를 구성한다.
•
ARIA 속성과 키보드 네비게이션 지원: 접근성을 보장하기 위한 기본적인 속성들이 내장되어 있다.
Context 기반 상태 관리
예를 들어 TreeContext는 트리 전체의 expand/collapse 상태를 관리한다.
export type TreeContextValue = {
contextType?: 'root';
level: number;
treeType: 'nested' | 'flat';
selectionMode: 'none' | SelectionMode;
appearance: 'subtle' | 'subtle-alpha' | 'transparent';
size: 'small' | 'medium';
openItems: ImmutableSet<TreeItemValue>;
checkedItems: ImmutableMap<TreeItemValue, 'mixed' | boolean>;
/**
* requests root Tree component to respond to some tree item event,
*/
requestTreeResponse(request: TreeItemRequest): void;
// FIXME: this is only marked as optional to avoid breaking changes
// it should always be provided internally
forceUpdateRovingTabIndex?(): void;
// FIXME: this is only marked as optional to avoid breaking changes
// it should always be provided internally
navigationMode?: 'tree' | 'treegrid';
};
const TreeContext = React.createContext<TreeContextValue | undefined>(undefined);
TypeScript
복사
각 TreeItem은 이 Context를 통해 자신의 상태를 구독하고, TreeItemLayout은 실제 UI를 렌더링하는 역할을 한다.
Tree는 아래처럼 되어있다. useTree_unstable 훅으로부터 Tree 컴포넌트의 state(open, contextType, root 등)을 가져온다.
export const Tree: ForwardRefComponent<TreeProps> = React.forwardRef((props, ref) => {
const state = useTree_unstable(props, ref);
const contextValues = useTreeContextValues_unstable(state);
useTreeStyles_unstable(state);
useCustomStyleHook_unstable('useTreeStyles_unstable')(state);
return renderTree_unstable(state, contextValues);
});
TypeScript
복사
useTree 훅 내부를 보면 SubtreeContext의 유무로 RootTree인지 SubTree인지 구분한다. 만약 Root면 RootTree, 아니면 SubTree state를 반환한다.
export const useTree_unstable = (props: TreeProps, ref: React.Ref<HTMLElement>): TreeState => {
'use no memo';
const isRoot = React.useContext(SubtreeContext) === undefined;
// as level is static, this doesn't break rule of hooks
// and if this becomes an issue later on, this can be easily converted
// eslint-disable-next-line react-hooks/rules-of-hooks
return isRoot ? useNestedRootTree(props, ref) : useNestedSubtree(props, ref);
};
TypeScript
복사
이 state를 기반으로 useTreeContextValues_unstable 훅에서 context values를 가져온다. 그리고 state와 contextValues를 통해 아래처럼 인터페이스에 맞게 tree 컴포넌트를 렌더링한다. 참고로 root는 useNestedRootTree 또는 useNestedSubtree에서 어떤 컴포넌트를 반환할지 정해진다.
export const renderTree_unstable = (state: TreeState, contextValues: TreeContextValues) => {
assertSlots<TreeSlots>(state);
return (
<TreeProvider value={contextValues.tree}>
{state.collapseMotion ? (
<state.collapseMotion>
<state.root />
</state.collapseMotion>
) : (
<state.root />
)}
</TreeProvider>
);
};
TypeScript
복사
TreeItem도 Tree와 같은 방식으로 TreeItemProvider를 통해 하위 자식들에게 자신의 컴포넌트를 공유한다. tree의 실제 UI를 보여주는 TreeItemLayout에서는 useTreeItemContext_unstable, useTreeContext_unstable 등을 통해 상위 컴포넌트의 상태를 가져오고 level이나 mode에 따라 각 트리아이템의 UI를 어떻게 보여줄 지 커스텀할 수 있다.
export const useTreeItemLayout_unstable = (
props: TreeItemLayoutProps,
ref: React.Ref<HTMLElement>,
): TreeItemLayoutState => {
const layoutRef = useTreeItemContext_unstable(ctx => ctx.layoutRef);
const selectionMode = useTreeContext_unstable(ctx => ctx.selectionMode);
const navigationMode = useTreeContext_unstable(ctx => ctx.navigationMode ?? 'tree');
// ... 생략
}
)
TypeScript
복사
Fluent UI의 React Tree에서 차용한 부분과 개선한 부분
•
Context 사용: 트리의 상태를 Context를 통해 관리하도록 하여, 중첩 구조에서도 일관된 상태 관리가 가능하게 했다.
•
Tree, TreeItem, TreeItemLayout 컴포넌트 분리: 선언적으로 트리를 작성할 수 있도록 했다.
그리고 다음과 같은 부분은 새롭게 추가하거나 개선하였다.
•
JSON 데이터 렌더링 지원: TreeWithJson 컴포넌트를 만들어 JSON 데이터만 넘겨도 트리가 바로 렌더링되도록 했다.
import { TreeData, TreeWithJson } from "@roseline124/react-tree";
import DropdownIcon from "./DropwonIcon";
const sampleData = {
"root leaf": {
root: "leaf",
},
customer: {
"customer information": {
name: "roseline",
birthday: "1980.01.01",
gender: "female",
},
address: {
defaultAddress: {
city: "seoul",
avenue: "gangnam",
},
detailAddress: "101-101",
},
},
} satisfies TreeData;
export default function CustomTreeWithJson() {
return (
<TreeWithJson
treeProps={{ open: true, dropDownIcon: <DropdownIcon /> }}
data={sampleData}
renderTreeItem={({
level: _level,
key,
value,
itemType,
keyPath: _keyPath,
}) => (
<div>
[{itemType}] {key} {typeof value === "string" ? `: ${value}` : ""}
</div>
)}
/>
);
}
TypeScript
복사
•
스타일링 유연성 강화: drop down 아이콘부터 각 트리 아이템까지, 하나하나 커스텀 스타일링이 가능하게 했다.
내가 만든 React Tree Component의 특징
내가 만든 React Tree Component는 다음과 같은 특징을 가진다.
•
선언적 API: React스럽게 컴포넌트 조합만으로 트리를 작성할 수 있다. (Tree, TreeItem, TreeItemLayout)
•
JSON 렌더링: JSON 데이터만 있으면 바로 트리를 구성할 수 있다. (TreeWithJson 사용)
•
커스터마이징 용이성: 노드마다 다른 UI를 쉽게 적용할 수 있다.
•
TypeScript 지원: 제네릭 기반 타입을 제공하여 안전성을 높였다.
사용 방법
npm install @roseline124/react-tree
yarn add @roseline124/react-tree
pnpm add @roseline124/react-tree
Bash
복사
선언적 사용
다음과 같이 계층적인 구조로 트리를 선언적으로 개발할 수 있다.
import { Tree, TreeItem, TreeItemLayout } from '@roseline124/react-tree';
function App() {
return (
<Tree aria-label="Default" open dropDownIcon={<DropdownIcon />}>
<TreeItem itemType="leaf">
<TreeItemLayout>customer id: 1234567890</TreeItemLayout>
</TreeItem>
<TreeItem itemType="branch">
<TreeItemLayout>customer information</TreeItemLayout>
<Tree>
<TreeItem itemType="leaf">
<TreeItemLayout>name: roseline</TreeItemLayout>
</TreeItem>
<TreeItem itemType="leaf">
<TreeItemLayout>birthday: 1980.01.01</TreeItemLayout>
</TreeItem>
<TreeItem itemType="leaf">
<TreeItemLayout>gender: female</TreeItemLayout>
</TreeItem>
</Tree>
</TreeItem>
</Tree>
);
}
TypeScript
복사
JSON 기반 사용
import { TreeWithJson } from '@roseline124/react-tree';
const data = [
{
id: '1',
label: 'Documents',
children: [
{ id: '2', label: 'Report.pdf' },
{ id: '3', label: 'Notes.txt' },
],
},
];
function App() {
return <TreeWithJson data={data} />;
}
TypeScript
복사
데모
마무리
Fluent UI React Tree는 훌륭한 설계를 가지고 있었지만, 내가 필요한 요구사항에는 맞지 않았다. 그래서 구조적인 아이디어는 유지하되, 쉽게 커스터마이징할 수 있고, JSON 데이터 렌더링에 특화된 React Tree Component를 만들게 되었다. 앞으로도 기능을 확장해 나갈 계획이며, 다양한 피드백과 기여를 환영한다.