4. 조립 전략
공용 컴포넌트는 단일, 합성, 명시적 변형 중 구조를 먼저 고르고 공개할 부품을 정합니다. 불리언 프롭으로 모드를 늘리지 않고, 정적 조립에는 렌더 프롭 대신 children을 씁니다.
4.1 단일, 합성, 변형 중 필요한 구조를 고릅니다
필요한 확장 범위에 맞춰 단순한 컴포넌트 구조를 선택합니다.
공용 컴포넌트는 프롭스보다 구조를 먼저 고르고, 현재 필요한 단계까지만 적용합니다.
flowchart LR
q1{"부품 조립이<br>필요한가?"} -- 예 --> q2{"부품이 같은 상태,<br>동작을 읽는가?"} -- 예 --> q3{"같은 조합이<br>반복되는가?"} -- 예 --> r4("조합을 감싼 변형")
q1 -- 아니요 --> r1("단일 컴포넌트")
q2 -- 아니요 --> r2("상태 없는 합성")
q3 -- 아니요 --> r3("상태 있는 합성")합성 진입 파일은 부품을 {Root, Header, Body} as const 객체 하나로 내보냅니다.
상태 있는 합성은 Root가 상태를 소유해 부품에 컨텍스트로 내리며, 상태를 추가해도 사용처의 공개 이름은 유지합니다.
반복되는 조합은 변형으로 감쌉니다.
단일, 합성, 변형을 구분하지 않고 한 컴포넌트에 모두 구현합니다.
export interface WgProfileDialogProps {
isCompact?: boolean;
showActivity?: boolean;
showFocus?: boolean;
dialogTitle?: string;
renderFooter?: () => ReactNode;
}
export const WgProfileDialog = (props: WgProfileDialogProps) => {
return (
<section className={props.isCompact ? "dialog dialog--compact" : "dialog"}>
<header>
<h3>{props.dialogTitle}</h3>
</header>
<WgProfileSummary />
{props.showActivity && <WgProfileActivityPanel />}
{props.showFocus && <WgProfileFocusPanel />}
<footer>{props.renderFooter?.()}</footer>
</section>
);
};
1단계. 확장이 필요 없으면 단일 컴포넌트로 둡니다.
/**
* 프로필 요약만 보여 주는 고정 구조 대화상자
*
* 사용처가 끼워 넣을 자리가 없어 부품으로 안 쪼갬
*/
export interface WgProfileDialogProps {
/**
* 헤더에 그릴 제목
*/
title: string;
/**
* 요약 영역에 그릴 프로필
*/
profile: Profile;
}
export const WgProfileDialog = (props: WgProfileDialogProps) => {
return (
<section className={clsx("wg_profileDialog__root")}>
<header className={clsx("wg_profileDialog__header")}>
<h3>{props.title}</h3>
</header>
<WgProfileSummary profile={props.profile} />
</section>
);
};
2단계. 사용처가 부품을 조립해야 하면 상태 없는 합성으로 엽니다.
component/widget/profile-dialog/
├── wg-profile-dialog.tsx 진입. 부품을 모아 내보냄
├── _wg-profile-dialog-root.tsx
├── _wg-profile-dialog-header.tsx
├── _wg-profile-dialog-body.tsx
└── _type/
└── profile-dialog-part.ts 세 부품이 나눠 쓰는 계약
/**
* 대화상자 부품 셋이 나눠 쓰는 계약
*
* 세 부품 모두 받는 것이 `children` 하나뿐이라 형태 하나로 통일
*/
export interface WgProfileDialogPartProps {
/**
* 그 부품 자리에 사용처가 넣을 내용
*/
children: ReactNode;
}import {clsx} from "clsx";
import type {WgProfileDialogPartProps} from "@/component/widget/profile-dialog/_type/profile-dialog-part";
/**
* 대화상자 틀. 나머지 부품은 이 안에서만 그림
*/
export const WgProfileDialogRoot = (props: WgProfileDialogPartProps) => {
return <section className={clsx("wg_profileDialog__root")}>{props.children}</section>;
};import {WgProfileDialogBody} from "@/component/widget/profile-dialog/_wg-profile-dialog-body";
import {WgProfileDialogHeader} from "@/component/widget/profile-dialog/_wg-profile-dialog-header";
import {WgProfileDialogRoot} from "@/component/widget/profile-dialog/_wg-profile-dialog-root";
export const WgProfileDialog = {
Root: WgProfileDialogRoot,
Header: WgProfileDialogHeader,
Body: WgProfileDialogBody,
} as const;3단계. 부품이 같은 상태를 읽으면 공개 이름을 그대로 두고 컨텍스트만 더합니다.
/**
* 대화상자 부품이 나눠 읽는 접힘 상태
*/
interface WgProfileDialogContextValue {
/**
* 본문이 펼쳐져 있는지
*/
isBodyOpen: boolean;
/**
* 헤더가 부르는 접기 토글
*/
toggleBody: () => void;
}
export const WgProfileDialogContext = createContext<WgProfileDialogContextValue | null>(null);/**
* 대화상자 틀. 접힘 상태를 소유하고 부품에 컨텍스트로 전달
*/
export const WgProfileDialogRoot = (props: WgProfileDialogPartProps) => {
const [isBodyOpen, setIsBodyOpen] = useState(true);
/**
* 헤더가 부를 접기 토글. 이전 값에 기대므로 함수형으로 갱신
*/
const toggleBody = () => {
setIsBodyOpen((previous) => !previous);
};
// Header는 useContext(WgProfileDialogContext)로 읽어 toggleBody 호출. 공개 이름은 2단계와 같음
return (
<WgProfileDialogContext value={{isBodyOpen, toggleBody}}>
<section className={clsx("wg_profileDialog__root")}>{props.children}</section>
</WgProfileDialogContext>
);
};4단계. 같은 조합이 반복되면 이름 붙인 변형으로 감쌉니다.
/**
* 읽기 전용 프로필 대화상자
*
* 세 화면이 같은 조합을 쓰므로 조립을 한 이름 뒤로 고정
*/
export interface WgReadOnlyProfileDialogProps {
/**
* 요약 영역에 그릴 프로필
*/
profile: Profile;
}
export const WgReadOnlyProfileDialog = (props: WgReadOnlyProfileDialogProps) => {
return (
<WgProfileDialog.Root>
<WgProfileDialog.Header>프로필 보기</WgProfileDialog.Header>
<WgProfileDialog.Body>
<WgProfileSummary profile={props.profile} />
</WgProfileDialog.Body>
</WgProfileDialog.Root>
);
};
함께 보기
- 4.2 합성 부품은 조립에 필요한 것만 공개합니다
- 4.3 공용 컴포넌트의 모드를 불리언 조합으로 늘리지 않습니다
- 4.4 정적 조립에서는 렌더 프롭 대신
children을 씁니다 - 6.2 화면 코드를 미리 추상화하지 않습니다
4.2 합성 부품은 조립에 필요한 것만 공개합니다
내부 구조를 공개 계약과 분리해 이후 변경 범위를 줄입니다.
합성 컴포넌트의 공개 부품은 사용처가 직접 조립해야 하는 영역만 엽니다.
| 영역 | 공개 여부 |
|---|---|
| 부품이 없으면 사용처가 자기 JSX를 넣을 수 없는 자리 | 공개합니다 |
| 공용 컨텍스트나 동작을 직접 쓰는 자리 | 공개합니다 |
단순 className 래퍼와 그 밖의 내부 구조 | 공개하지 않습니다 |
| 여백 보정용 DOM | CSS 규칙 3.5에 따라 만들지 않습니다 |
내부 구조를 전부 공개해 계약으로 굳힙니다.
import {UiPanelBody} from "@/component/ui/panel/_ui-panel-body";
import {UiPanelHeader} from "@/component/ui/panel/_ui-panel-header";
import {UiPanelHeaderInner} from "@/component/ui/panel/_ui-panel-header-inner";
import {UiPanelRoot} from "@/component/ui/panel/_ui-panel-root";
import {UiPanelSpacer} from "@/component/ui/panel/_ui-panel-spacer";
// 사용처가 끼워 넣을 자리가 없는 래퍼와 여백 보정용 DOM까지 이름이 붙어 노출
export const UiPanel = {
Root: UiPanelRoot,
Header: UiPanelHeader,
HeaderInner: UiPanelHeaderInner,
Spacer: UiPanelSpacer,
Body: UiPanelBody,
} as const;조립에 필요한 것만 공개합니다.
import {UiPanelBody} from "@/component/ui/panel/_ui-panel-body";
import {UiPanelHeader} from "@/component/ui/panel/_ui-panel-header";
import {UiPanelRoot} from "@/component/ui/panel/_ui-panel-root";
// 단순 클래스 래퍼는 헤더 부품만 쓰고 여백 보정용 DOM은 안 만듦
export const UiPanel = {
Root: UiPanelRoot,
Header: UiPanelHeader,
Body: UiPanelBody,
} as const;함께 보기
4.3 공용 컴포넌트의 모드를 불리언 조합으로 늘리지 않습니다
모드별 분기와 조합을 컴포넌트 구조에서 확인할 수 있습니다.
여러 파일, 레이어에서 재사용하는 공용 ui, widget은 모드별 불리언 조합 대신 구조를 드러냅니다.
isCompact, isEditing, showSearch가 늘어나면 가능한 조합과 JSX, 스타일 분기도 함께 늘어납니다.
| 조건 | 판정 |
|---|---|
| 모양이나 모드를 정하는 불리언이 둘 이상임 | 변형 또는 합성 컴포넌트로 구조를 다시 고릅니다 |
| 같은 불리언을 JSX 분기와 클래스 조건에 함께 사용함 | 변형 또는 합성 컴포넌트로 구조를 다시 고릅니다 |
disabled, checked, selected, open처럼 독립 상태를 나타냄 | 유지합니다. 불리언이라는 이유만으로 없애지 않습니다 |
불리언 개수 자체보다 서로 배타적인 모드를 조합으로 표현하는지 확인합니다.
공개 부품을 .Root처럼 묶는 형태는 규칙 4.1를 따릅니다.
불리언 프롭 조합으로 공용 컴포넌트가 비대해집니다.
export interface WgProductToolbarProps {
isCompact?: boolean;
isEditing?: boolean;
showSearch?: boolean;
}
export const WgProductToolbar = (props: WgProductToolbarProps) => {
return (
<header>
{props.showSearch && <WgProductSearchField />}
{props.isEditing ? (
<WgProductEditActions compact={props.isCompact} />
) : (
<WgProductBrowseActions compact={props.isCompact} />
)}
</header>
);
};모드마다 조합을 고정한 변형 컴포넌트를 둡니다.
import {WgProductToolbar} from "@/component/widget/product-toolbar/wg-product-toolbar";
export const WgProductBrowseToolbar = () => {
return (
<WgProductToolbar.Root>
<WgProductSearchField />
<WgProductBrowseActions />
</WgProductToolbar.Root>
);
};변형이 조립하는 틀 부품과 진입 파일, 나머지 변형입니다.
/**
* 툴바 바깥 틀 부품
*/
export interface WgProductToolbarRootProps {
/**
* 툴바 줄에 늘어놓을 검색과 동작 부품
*/
children: ReactNode;
}
export const WgProductToolbarRoot = (props: WgProductToolbarRootProps) => {
return <header className={clsx("wg_productToolbar__root")}>{props.children}</header>;
};import {WgProductToolbarRoot} from "@/component/widget/product-toolbar/_wg-product-toolbar-root";
// 조합은 두 변형이 이미 제공하므로 사용처가 직접 조립할 `Root`만 공개
export const WgProductToolbar = {
Root: WgProductToolbarRoot,
} as const;import {WgProductToolbar} from "@/component/widget/product-toolbar/wg-product-toolbar";
export const WgProductEditToolbar = () => {
return (
<WgProductToolbar.Root>
<WgProductEditActions />
</WgProductToolbar.Root>
);
};함께 보기
4.4 정적 조립에서는 렌더 프롭 대신 children을 씁니다
실행 문맥이 필요 없는 조립을 JSX 구조로 바로 읽을 수 있습니다.
상태 없는 합성으로 충분한 공용 컴포넌트는 렌더 프롭보다 children을 우선합니다.
flowchart LR
q1{"부모가 항목, 순번, 상태 같은<br>실행 문맥을 자식에게 넘기는가?"} -- 아니요 --> r1("children 과<br>네임스페이스 슬롯 부품")
q1 -- 예 --> r2("renderHeader, renderFooter<br>같은 렌더 프롭")| 별도 이름이 필요한 계약 | 이름 |
|---|---|
ReactNode 값 | <Owner>Slot |
실행 문맥을 받아 ReactNode를 만드는 함수 | <Owner>Renderer |
| 한 번만 쓰는 익명 형태 | 접미사를 붙이려고 새 타입을 만들지 않습니다 |
정적인 구조를 렌더 프롭으로 조립합니다.
export interface UiPanelProps {
renderHeader?: () => ReactNode;
renderFooter?: () => ReactNode;
}
export const UiPanel = (props: UiPanelProps) => {
return (
<section className={clsx("ui_panel__root")}>
{props.renderHeader?.()}
<UiItemList />
{props.renderFooter?.()}
</section>
);
};부품이 children으로 사용처가 넣을 자리를 엽니다.
import {clsx} from "clsx";
import type {UiPanelPartProps} from "@/component/ui/panel/_type/panel-part";
/**
* 패널 틀. 나머지 부품은 이 안에서만 그림
*/
export const UiPanelRoot = (props: UiPanelPartProps) => {
return <section className={clsx("ui_panel__root")}>{props.children}</section>;
};부품 계약, 진입 파일, 화면 조립을 파일마다 나눕니다.
/**
* 패널 부품 셋이 나눠 쓰는 계약
*
* 세 부품 모두 받는 것이 `children` 하나뿐이라 형태 하나로 통일
*/
export interface UiPanelPartProps {
/**
* 그 부품 자리에 사용처가 넣을 내용
*/
children: ReactNode;
}import {UiPanelFooter} from "@/component/ui/panel/_ui-panel-footer";
import {UiPanelHeader} from "@/component/ui/panel/_ui-panel-header";
import {UiPanelRoot} from "@/component/ui/panel/_ui-panel-root";
// Header와 Footer도 Root와 같은 형태로 children만 받음
export const UiPanel = {
Root: UiPanelRoot,
Header: UiPanelHeader,
Footer: UiPanelFooter,
} as const;import {Fragment} from "react";
import {UiPanel} from "@/component/ui/panel/ui-panel";
export const PgProducts = () => {
return (
<Fragment>
<UiPanel.Root>
<UiPanel.Header>
<h2>제품</h2>
<PgProductSearchField />
</UiPanel.Header>
<PgProductList />
<UiPanel.Footer>
<UiPagination />
</UiPanel.Footer>
</UiPanel.Root>
<UiPanel.Root>
<UiPanel.Header>
<h2>제품 등록</h2>
</UiPanel.Header>
<PgProductCreateForm />
</UiPanel.Root>
</Fragment>
);
};