Navigation

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         세 부품이 나눠 쓰는 계약
component/widget/profile-dialog/_type/profile-dialog-part.tstsx
/**
 * 대화상자 부품 셋이 나눠 쓰는 계약
 *
 * 세 부품 모두 받는 것이 `children` 하나뿐이라 형태 하나로 통일
 */
export interface WgProfileDialogPartProps {
	/**
	 * 그 부품 자리에 사용처가 넣을 내용
	 */
	children: ReactNode;
}
component/widget/profile-dialog/_wg-profile-dialog-root.tsxtsx
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>;
};
component/widget/profile-dialog/wg-profile-dialog.tsxtsx
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단계. 부품이 같은 상태를 읽으면 공개 이름을 그대로 두고 컨텍스트만 더합니다.

component/widget/profile-dialog/_hook/use-profile-dialog.tsts
/**
 * 대화상자 부품이 나눠 읽는 접힘 상태
 */
interface WgProfileDialogContextValue {
	/**
	 * 본문이 펼쳐져 있는지
	 */
	isBodyOpen: boolean;
	/**
	 * 헤더가 부르는 접기 토글
	 */
	toggleBody: () => void;
}

export const WgProfileDialogContext = createContext<WgProfileDialogContextValue | null>(null);
component/widget/profile-dialog/_wg-profile-dialog-root.tsxtsx
/**
 * 대화상자 틀. 접힘 상태를 소유하고 부품에 컨텍스트로 전달
 */
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 합성 부품은 조립에 필요한 것만 공개합니다

내부 구조를 공개 계약과 분리해 이후 변경 범위를 줄입니다.

합성 컴포넌트의 공개 부품은 사용처가 직접 조립해야 하는 영역만 엽니다.

영역공개 여부
부품이 없으면 사용처가 자기 JSX를 넣을 수 없는 자리공개합니다
공용 컨텍스트나 동작을 직접 쓰는 자리공개합니다
단순 className 래퍼와 그 밖의 내부 구조공개하지 않습니다
여백 보정용 DOMCSS 규칙 3.5에 따라 만들지 않습니다

내부 구조를 전부 공개해 계약으로 굳힙니다.

component/ui/panel/ui-panel.tsxtsx
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;

조립에 필요한 것만 공개합니다.

component/ui/panel/ui-panel.tsxtsx
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를 따릅니다.

불리언 프롭 조합으로 공용 컴포넌트가 비대해집니다.

component/widget/product-toolbar/wg-product-toolbar.tsxtsx
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>
	);
};

모드마다 조합을 고정한 변형 컴포넌트를 둡니다.

component/widget/product-browse-toolbar/wg-product-browse-toolbar.tsxtsx
import {WgProductToolbar} from "@/component/widget/product-toolbar/wg-product-toolbar";

export const WgProductBrowseToolbar = () => {
	return (
		<WgProductToolbar.Root>
			<WgProductSearchField />
			<WgProductBrowseActions />
		</WgProductToolbar.Root>
	);
};

변형이 조립하는 틀 부품과 진입 파일, 나머지 변형입니다.

component/widget/product-toolbar/_wg-product-toolbar-root.tsxtsx
/**
 * 툴바 바깥 틀 부품
 */
export interface WgProductToolbarRootProps {
	/**
	 * 툴바 줄에 늘어놓을 검색과 동작 부품
	 */
	children: ReactNode;
}

export const WgProductToolbarRoot = (props: WgProductToolbarRootProps) => {
	return <header className={clsx("wg_productToolbar__root")}>{props.children}</header>;
};
component/widget/product-toolbar/wg-product-toolbar.tsxtsx
import {WgProductToolbarRoot} from "@/component/widget/product-toolbar/_wg-product-toolbar-root";

// 조합은 두 변형이 이미 제공하므로 사용처가 직접 조립할 `Root`만 공개
export const WgProductToolbar = {
	Root: WgProductToolbarRoot,
} as const;
component/widget/product-edit-toolbar/wg-product-edit-toolbar.tsxtsx
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
한 번만 쓰는 익명 형태접미사를 붙이려고 새 타입을 만들지 않습니다

정적인 구조를 렌더 프롭으로 조립합니다.

component/ui/panel/ui-panel.tsxtsx
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으로 사용처가 넣을 자리를 엽니다.

component/ui/panel/_ui-panel-root.tsxtsx
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>;
};

부품 계약, 진입 파일, 화면 조립을 파일마다 나눕니다.

component/ui/panel/_type/panel-part.tsts
/**
 * 패널 부품 셋이 나눠 쓰는 계약
 *
 * 세 부품 모두 받는 것이 `children` 하나뿐이라 형태 하나로 통일
 */
export interface UiPanelPartProps {
	/**
	 * 그 부품 자리에 사용처가 넣을 내용
	 */
	children: ReactNode;
}
component/ui/panel/ui-panel.tsxtsx
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;
page/products/pg-products.tsxtsx
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>
	);
};