Navigation

1. 소유와 경계

ui, widget, page 세 레이어의 소유 경계가 분명해야 코드를 예측 가능하게 배치할 수 있습니다. 레이어와 역할에 맞춰 이름과 폴더를 정하고, 가져오기는 하위 레이어로만 향합니다. 공통 라우트 프레임은 Outlet 그룹으로 묶고 화면 아래 소유자는 한 겹으로 유지합니다. 생명주기는 해당 컴포넌트가 관리하고 순수 계산은 훅으로 감싸지 않습니다.

1.1 컴포넌트를 ui, widget, page 소유 레이어로 나눕니다

공용 책임과 화면 전용 책임이 같은 레이어에 섞이지 않습니다.

컴포넌트의 레이어는 사용 횟수나 조립 규모가 아니라 무엇을 아는지로 나눕니다.

flowchart LR
	q1{"화면의 데이터와<br>흐름을 아는가?"} -- 아니요 --> q2{"도메인을 아는가?"} -- 아니요 --> r3("ui")
	q1 -- 예 --> r1("page")
	q2 -- 예 --> r2("widget")

아래 조건 중 하나라도 걸리면 page입니다.

조건레이어
화면의 응답, 뷰모델 타입이나 라우트 search 파라미터를 프롭스 타입에서 참조함page
쿼리, 뮤테이션, 라우터 훅, 화면 스토어를 직접 호출함page
해당 화면의 Suspense 경계, 폼 프로바이더, 모달을 여는 조건을 소유함page

하나도 걸리지 않으면 도메인을 아는 쪽이 widget, 모르는 쪽이 ui입니다. 범용 셸이거나 한 화면에서만 쓴다는 사실은 page의 근거가 아니고, 여러 ui 부품을 조립해도 도메인을 모르면 ui입니다. 파일 이름과 심볼에는 규칙 1.2를 적용합니다.

라우터 훅을 부르는 화면 전용 로직이 widget에 남아 있습니다.

component/widget/product-toolbar/_wg-product-toolbar-delete-button.tsxtsx
export const WgProductToolbarDeleteButton = () => {
	const navigate = useNavigate();

	/**
	 * 삭제 후 목록으로 이동
	 */
	const handleDeleteButtonClick: MouseEventHandler<HTMLButtonElement> = () => {
		void navigate("/products");
	};

	return <UiButton onClick={handleDeleteButtonClick}>삭제</UiButton>;
};

라우터 훅을 호출하는 코드는 화면 레이어에 둡니다.

page/products/_pg-delete-product-button.tsxtsx
export const PgDeleteProductButton = () => {
	const navigate = useNavigate();

	/**
	 * 삭제 후 목록으로 이동
	 */
	const handleDeleteButtonClick: MouseEventHandler<HTMLButtonElement> = () => {
		void navigate("/products");
	};

	return <UiButton onClick={handleDeleteButtonClick}>삭제</UiButton>;
};

화면 타입, 훅과 무관한 부품을 사용 횟수만으로 화면 레이어에 둡니다.

page/product-detail/_pg-product-status-badge.tsxtsx
/**
 * 상품 상태 배지 계약
 */
export interface PgProductStatusBadgeProps {
	/**
	 * 배지 색을 정하는 상품 상태
	 */
	status: ProductStatus;
	children: ReactNode;
}

// 도메인 타입 하나만 알고 화면 타입과 훅은 모름. 이 화면에서만 쓴다는 이유로 page에 남아 있음
export const PgProductStatusBadge = (props: PgProductStatusBadgeProps) => {
	return (
		<span
			className={clsx(
				"pg_productDetail__statusBadge",
				props.status === "hidden" && "pg_productDetail__statusBadge--hidden",
			)}
		>
			{props.children}
		</span>
	);
};

화면 타입, 훅과 무관한 도메인 부품은 widget에 둡니다.

component/widget/product-table/_wg-product-status-badge.tsxtsx
/**
 * 상품 상태 배지 계약
 */
export interface WgProductStatusBadgeProps {
	/**
	 * 배지 색을 정하는 상품 상태
	 */
	status: ProductStatus;
	children: ReactNode;
}

export const WgProductStatusBadge = (props: WgProductStatusBadgeProps) => {
	return (
		<span
			className={clsx(
				"wg_productTable__statusBadge",
				props.status === "hidden" && "wg_productTable__statusBadge--hidden",
			)}
		>
			{props.children}
		</span>
	);
};

도메인을 모르는 그래프 그리기를 조립 규모만 보고 widget에 둡니다.

component/widget/line-chart/wg-line-chart.tsxtsx
/**
 * 꺾은선 그래프 계약
 */
export interface WgLineChartProps {
	/**
	 * 그래프의 접근 가능한 이름
	 */
	title: string;
	/**
	 * 그릴 좌표. 어느 도메인의 값인지 모름
	 */
	points: ChartPoint[];
}

// 좌표 배열만 받고 도메인 타입을 모름. 조립 규모만 보고 widget에 둠
export const WgLineChart = (props: WgLineChartProps) => {
	return (
		<svg className={clsx("wg_lineChart__root")} viewBox="0 0 100 40">
			<title>{props.title}</title>
			<polyline points={props.points.map((point) => `${point.x},${point.y}`).join(" ")} />
		</svg>
	);
};

도메인 지식이 없는 조합은 ui에 둡니다.

component/ui/line-chart/ui-line-chart.tsxtsx
/**
 * 꺾은선 그래프 계약
 */
export interface UiLineChartProps {
	/**
	 * 그래프의 접근 가능한 이름
	 */
	title: string;
	/**
	 * 그릴 좌표. 어느 도메인의 값인지 모름
	 */
	points: ChartPoint[];
}

export const UiLineChart = (props: UiLineChartProps) => {
	return (
		<svg className={clsx("ui_lineChart__root")} viewBox="0 0 100 40">
			<title>{props.title}</title>
			<polyline points={props.points.map((point) => `${point.x},${point.y}`).join(" ")} />
		</svg>
	);
};

도메인을 아는 조립은 widget이 맡아 ui 부품을 씁니다.

component/widget/chart-card/wg-chart-card.tsxtsx
import {UiLineChart} from "@/component/ui/line-chart/ui-line-chart";

export const WgChartCard = (props: WgChartCardProps) => {
	return <UiLineChart title="일별 주문 수" points={toChartPoints(props.dailyCounts)} />;
};

함께 보기

1.2 파일 이름과 심볼에 레이어 접두사를 붙입니다

파일 하나만 봐도 어느 레이어 소유인지 드러납니다.

세 레이어 모두 파일 이름과 심볼에 레이어 접두사를 붙입니다. 레이어 판정은 규칙 1.1를 따릅니다.

레이어 접두사

레이어파일심볼CSS 식별자
uiui-button.tsxUiButtonui_button
widgetwg-chart.tsxWgChartwg_chart
pagepg-detail.tsxPgDetailpg_detail
대상표기
폴더상위 폴더가 레이어를 나타내므로 접두사를 붙이지 않습니다
진입 파일이 아닌 컴포넌트_pg-unit-toggle.tsx처럼 접두사 앞에 _를 붙입니다. 동반 .css도 같은 이름을 씁니다
심볼진입 파일 여부와 관계없이 _를 붙이지 않습니다
접두사와 겹치는 이름component/ui/button/ui-button.tsx로 쓰고 ui-button-button.tsx처럼 반복하지 않습니다

부품 이름 짓기

부품과 하위 소유자는 이름만 보고 무엇인지 알 수 있게 짓습니다.

부품 이름예
역할 낱말 하나만header, item, panel은 안 됨
무엇의 것인지 앞에table-col, chat-message, disruptor-guide-modal
소유자 이름필수가 아니라 방법 하나

진입 파일의 기준은 규칙 1.3를 따릅니다.

화면 컴포넌트의 접두사를 누락합니다.

page/product-detail/product-table-section.tsxtsx
export const ProductTable = (props: ProductTableProps) => {
	return <section className={clsx("pg_productTableSection__root")}>{props.children}</section>;
};

진입 파일이 아닌 파일에는 _를 붙이고 파일 이름과 심볼에 레이어 접두사를 씁니다.

page/product-detail/_pg-product-table-section.tsxtsx
export const PgProductTableSection = (props: PgProductTableSectionProps) => {
	return <section className={clsx("pg_productTableSection__root")}>{props.children}</section>;
};

폴더에도 접두사를 붙이고 이름에서 되풀이합니다.

component/ui/ui-button/ui-button-button.tsxtsx
export const UiButtonButton = (props: UiButtonButtonProps) => {
	return <button type="button">{props.children}</button>;
};

폴더에는 접두사를 붙이지 않고 파일 이름에서 같은 말을 반복하지 않습니다.

component/ui/button/ui-button.tsxtsx
export const UiButton = (props: UiButtonProps) => {
	return <button type="button">{props.children}</button>;
};

역할 낱말 하나로 지어 무엇의 부품인지 알 수 없습니다.

component/widget/chatbot/
├── _wg-launcher.tsx    # WgLauncher, wg_launcher
└── panel/
    ├── wg-panel.tsx    # WgPanel, wg_panel
    └── _wg-header.tsx  # WgHeader, wg_header

이름만 보고 뜻을 알 수 있고 소유자 이름은 필요할 때만 들어갑니다.

component/widget/chatbot/
├── _wg-chatbot-launcher.tsx       # WgChatbotLauncher
└── chat-panel/
    ├── wg-chat-panel.tsx          # WgChatPanel, wg_chatPanel
    ├── _wg-chat-panel-header.tsx  # WgChatPanelHeader
    └── _wg-history-pane.tsx       # WgHistoryPane. history가 뜻을 만들어 소유자 이름이 필요 없음

함께 보기

1.3 추출한 파일은 소유자 아래 역할 폴더에 둡니다

추출한 파일의 소유자와 역할을 경로에서 확인할 수 있습니다.

추출한 파일은 소유자 폴더에 두고, 역할과 공개 범위에 맞춰 이름을 정합니다. 화면 내부의 호출 계층은 폴더를 중첩하지 않고 진입 파일의 조립으로 드러냅니다. 라우트 그룹과 Outlet 프레임의 자리는 규칙 1.5가 정합니다.

배치와 이름

구분배치와 이름
소유자자기만 쓰는 파일이 있거나 여러 하위 소유자가 함께 쓰는 컴포넌트는 자기 이름의 폴더를 갖습니다
진입 파일레이어 접두사를 뺀 이름을 폴더와 맞춥니다. 한 폴더에 라우트가 여럿이면 첫 진입은 pg-<folder>, 나머지는 pg-<folder>-<변형>입니다
부품역할 폴더에 넣지 않고 소유자 폴더의 _ 파일로 두며, _ 파일은 같은 폴더에서만 가져옵니다. 동반 .css도 같은 이름을 씁니다
하위 소유자각 화면, ui, widget 소유자 안에 한 겹만 두고, 이름은 panel처럼 역할 낱말 하나로 짓지 않습니다
역할 폴더필요한 것만 만들고 파일이 하나여도 유지합니다. 아래 네 종류만 허용합니다
함수의 보조 파일전용 보조 파일이 있는 함수만 _function 아래 자기 이름 폴더를 갖습니다

부품 하나만 있어도 소유자 폴더를 만들고, 라우트는 항상 소유자입니다. 전용 지원 코드가 없는 화면은 page/pg-not-found.tsx처럼 page나 라우트 그룹 바로 아래에 파일로 둘 수 있습니다. 동반 CSS만 있어도 폴더는 필요하지 않습니다. 소유자 안에서 역할 폴더 네 개를 제외한 폴더는 모두 하위 소유자입니다. 하위 소유자가 한 겹보다 깊어지면 형제로 올리거나 widget으로 분리할지 판단합니다.

역할 폴더

역할 폴더담는 것
_constant입력을 받지 않는 상수, 기본값, 기준값, 파서 묶음 등 선언형 계약
_function이름 붙여 내보낸 도메인 계산
_hook실제 상태, 이펙트, 컨텍스트를 소유한 커스텀 훅
_type여러 파일이 공유하는 계약. 개별 컴포넌트의 프롭스는 해당 TSX에 둡니다

소유자 폴더에서 _가 없는 이름은 진입 파일과 하위 소유자 폴더뿐입니다. _ 파일은 같은 폴더에서만 가져오고, 역할 폴더는 외부에서도 가져올 수 있는 공개 영역입니다. 가져오기 경계는 규칙 1.4를 따릅니다.

폴더 이름 기준

폴더 이름은 단수로 쓰되 프레임워크가 강제하는 이름과 라우트 그룹 표기는 예외입니다. 소유자 아래에 component, util, helper, config, constants, common, shared 폴더를 만들지 않습니다. 루트의 constant, type, hook은 프로젝트가 소유하는 역할 폴더이므로 같은 규칙을 따르되 _를 붙이지 않습니다.

함께 판단할 내용기준
보조 함수 추출TypeScript 규칙 3.3
보조 함수의 파일 분리TypeScript 규칙 3.4
파일 이름과 심볼의 접두사규칙 1.2
루트에만 두는 utilTypeScript 규칙 3.6
루트에만 두는 configTypeScript 규칙 2.6

단순 컴포넌트에 역할 폴더를 미리 다 만듭니다.

component/ui/button/
├── ui-button.tsx
├── ui-button.css
├── _constant/
├── _function/
├── _hook/
└── _type/

역할 폴더를 미리 만들지 않습니다.

component/ui/button/
├── ui-button.tsx
└── ui-button.css

범용 이름 폴더를 섞어 쓰고 하위 소유자 안에 소유자를 다시 둡니다.

page/product-detail/
├── pg-product-detail.tsx
├── components/
├── constants/
├── utils/
├── helpers/
└── product-table-section/
    ├── pg-product-table-section.tsx
    └── review/
        ├── pg-review.tsx
        └── _function/
            └── to-review-rows.ts

필요한 역할 폴더만 만들고 부품은 파일로 둡니다.

page/product-detail/
├── pg-product-detail.tsx
├── pg-product-detail.css
├── _pg-product-summary.tsx            자기만 쓰는 파일이 없어 파일로 둠
├── _pg-product-summary.css
├── _function/
│   ├── to-product-summary.ts
│   └── to-trend-chart/                자기만 쓰는 보조가 있어 폴더
│       ├── to-trend-chart.ts
│       └── _to-chart-range.ts         toTrendChart만 부름
├── _type/
│   └── product-detail-view-model.ts
└── product-table-section/             자기만 쓰는 파일이 있어 하위 소유자 폴더가 됨
    ├── pg-product-table-section.tsx
    ├── pg-product-table-section.css
    ├── _pg-review-section.tsx
    └── _function/
        └── to-chart-range.ts

함께 보기

1.4 컴포넌트 가져오기는 레이어와 소유자 경계를 지킵니다

공개 범위를 벗어난 가져오기를 막아 컴포넌트의 소유 관계를 유지합니다.

가져오기는 아래 레이어 방향과 소유자 경계를 모두 지킵니다. 모든 경로가 @/로 시작하므로 경로 모양이 아니라 가져오는 파일의 위치로 판정합니다. 소유자, 진입 파일, 역할 폴더의 정의는 규칙 1.3를 따릅니다.

레이어 방향

가져오기는 화살표 방향으로만 흐르고, 중간 레이어는 건너뛸 수 있습니다.

flowchart LR
	router("라우터, 앱 진입 파일") --> page("page")
	page --> widget("component/widget")
	widget --> ui("component/ui")
	ui --> root("루트 레이어<br>util, constant, type, hook<br>store, service, config, asset")
가져오는 쪽가져올 수 있는 레이어
루트 레이어루트 레이어
component/ui루트 레이어, ui
component/widget루트 레이어, ui, widget
page루트 레이어, ui, widget
라우터와 앱 진입 파일전부

같은 레이어의 공개 컴포넌트끼리 조립할 수 있지만 순환 가져오기는 만들지 않습니다. 루트 레이어는 util, constant, type, hook, store, service, config, asset입니다.

소유자 경계

가져오려는 대상가져올 수 있는 파일
ui, widget의 진입 파일레이어 방향을 지키는 파일
화면과 Outlet 프레임의 진입 파일라우터
다른 화면이나 Outlet 프레임 안의 파일없음. 같은 그룹 안의 화면끼리도 같습니다
하위 소유자의 진입 파일그 하위 소유자를 담은 소유자 폴더 아래의 파일
_로 시작하는 파일같은 폴더의 파일
_function, _type, _constant, _hook의 파일레이어 방향을 지키는 파일. 다른 라우트의 역할 폴더는 제외합니다

_ 컴포넌트 파일의 프롭스 타입은 예외로, 어디서든 import type으로 가져옵니다. 역할 폴더는 소유자의 공개 영역이므로 외부에서 쓴다는 이유만으로 루트로 옮기지 않습니다.

여러 자식이 함께 쓰는 컴포넌트

여러 자식이 같은 컴포넌트를 쓰면 부모가 조립해 프롭, children으로 내려보내거나, 화면 조립에 종속되지 않을 때 ui, widget으로 옮깁니다. 짧은 조각은 중복해서 써도 됩니다. 세 자식 이상이 공유해야 하는데 공용 레이어로 옮길 수도 없다면 자식 분리 자체를 다시 봅니다.

다른 폴더의 _ 컴포넌트 파일을 가져옵니다.

page/product-detail/product-table-section/pg-product-table-section.tsxtsx
import {PgSectionHeading} from "@/page/product-detail/_pg-section-heading";

_ 파일과 같은 폴더에 있는 진입 파일이 조립해서 프롭으로 내려보냅니다.

page/product-detail/pg-product-detail.tsxtsx
import {PgSectionHeading} from "@/page/product-detail/_pg-section-heading";
import {PgProductTableSection} from "@/page/product-detail/product-table-section/pg-product-table-section";
import {PgProductSummary} from "@/page/product-detail/product-summary/pg-product-summary";

export const PgProductDetail = () => {
	return (
		<main className={clsx("pg_productDetail__root")}>
			<PgProductTableSection heading={<PgSectionHeading title="최근 주문" />} />
			<PgProductSummary heading={<PgSectionHeading title="요약" />} />
		</main>
	);
};

다른 라우트 안의 컴포넌트를 가져옵니다.

page/order-detail/pg-order-detail.tsxtsx
import {PgProductTableSection} from "@/page/product-detail/product-table-section/pg-product-table-section";

두 라우트가 공유하는 화면 독립 컴포넌트는 공용 레이어에 둡니다.

component/widget/product-table/wg-product-table.tsxtsx
export const WgProductTable = (props: WgProductTableProps) => {
	return <section className={clsx("wg_productTable__root")}>{props.children}</section>;
};
page/order-detail/pg-order-detail.tsxtsx
import {WgProductTable} from "@/component/widget/product-table/wg-product-table";

ui가 widget을 가져옵니다.

component/ui/line-chart/ui-line-chart.tsxtsx
import {WgChartCard} from "@/component/widget/chart-card/wg-chart-card";

방향을 뒤집어 widget이 ui를 가져옵니다.

component/widget/chart-card/wg-chart-card.tsxtsx
import {UiLineChart} from "@/component/ui/line-chart/ui-line-chart";

외부에서 사용한다는 이유만으로 역할 폴더의 파일을 루트로 옮깁니다.

type/chart-series.tsts
// component/ui/line-chart/_type에 있던 것을 page에서도 쓴다고 루트로 옮김
export interface ChartSeries {
	/**
	 * 선 하나가 그리는 좌표
	 */
	points: ChartPoint[];
}

// page/product-detail/product-table-section/_function/to-chart-option.ts
import type {ChartSeries} from "@/type/chart-series";

역할 폴더의 파일은 레이어 방향만 지키면 밖에서도 가져옵니다.

page/product-detail/product-table-section/_function/to-chart-option.tsts
import type {ChartSeries} from "@/component/ui/line-chart/_type/chart-series";
import {chart_series_line} from "@/component/ui/line-chart/_constant/series";

함께 보기

1.5 공통 라우트 프레임은 Outlet 그룹으로 묶습니다

라우트 중첩을 폴더로 드러내면서 화면 아래 한 겹 소유 경계를 지킵니다.

라우트의 중첩과 화면 안 컴포넌트의 중첩은 서로 다른 축입니다. 공통 외곽이 필요한 자리에만 Outlet 프레임을 두고, 폴더의 포함 관계를 라우트 선언과 맞춥니다. 파일 기반 라우터를 쓰는 프로젝트는 그 라우터가 강제하는 표기를 먼저 따릅니다.

진입 파일과 그룹

대상배치와 책임
기본 프레임page/pg-outlet.tsx. 모든 화면의 공통 외곽과 Outlet을 소유합니다
경로 없는 그룹page/(main)/pg-main-outlet.tsx. 폴더의 (이름)과 진입 파일의 pg-<이름>-outlet을 맞춥니다
화면지원 코드가 있으면 page/(main)/products/pg-products.tsx, 없으면 page/pg-not-found.tsx. 각각 독립 소유자입니다
라우터URL과 진입 파일의 중첩 관계를 선언합니다

그룹은 page 바로 아래나 다른 그룹 아래에 두고, 그룹마다 자식 라우트를 감싸는 Outlet 진입 파일을 둡니다. 분류만 하는 그룹, Outlet만 렌더하는 빈 프레임, page 전체를 한 번 더 감싸는 (app) 같은 그룹은 만들지 않습니다. (이름) 폴더는 URL에 나타나지 않으며, 폴더를 만드는 것만으로 라우트가 생기지도 않습니다.

깊이와 소유자 경계

화면 폴더부터는 하위 소유자 한 겹 제한이 그대로 적용되고, 화면이나 하위 소유자 아래에는 그룹을 만들지 않습니다. 더 깊은 URL이 필요해도 화면 폴더는 다른 화면과 형제로 두고 중첩은 라우터가 선언합니다. 기본 프레임, 그룹 프레임, 각 화면은 서로 다른 소유자입니다. 다른 프레임이나 화면의 _ 부품과 역할 폴더는 같은 그룹 안에서도 가져오지 않습니다. 프레임은 자식 화면을 직접 가져오지 않고 Outlet으로 받으며, 진입 파일을 가져오는 것은 라우터뿐입니다.

모든 자식 화면에 적용하는 인증 가드는 기본 프레임이 직접 소유할 수 있습니다. 인증 확인이 끝나기 전에는 Outlet 아래를 렌더하지 않습니다. 화면마다 다른 접근 정책은 라우터가 연결하며, page는 라우터의 구현을 가져오지 않습니다. 같은 화면을 여러 URL에서 열면 라우터가 같은 진입 파일을 연결하고, 차이는 params나 진입 프롭으로 전달합니다.

프레임이 유지하는 것

자식 화면이 바뀌어도 남아야 하는 제목, 탭은 그 프레임이 소유하고, 화면마다 다른 데이터는 프레임에 모으지 않습니다. 프레임보다 위에서 URL 경로마다 key를 바꾸면 프레임까지 다시 마운트되므로 쓰지 않습니다. 오류와 로딩 경계의 자리는 규칙 7.3와 규칙 7.1를 따릅니다.

화면 아래에 그룹을 만들어 소유자를 한 겹 더 쌓습니다.

page/
├── pg-outlet.tsx
└── (main)/
    ├── pg-main-outlet.tsx
    ├── products/
    │   ├── pg-products.tsx
    │   ├── product-table-section/
    │   │   └── pg-product-table-section.tsx
    │   └── (detail)/
    │       ├── pg-detail-outlet.tsx
    │       └── product-detail/
    │           └── pg-product-detail.tsx
    └── orders/
        └── pg-orders.tsx

더 깊은 URL을 쓰는 화면도 그룹 밖 형제 소유자로 둡니다.

page/
├── pg-outlet.tsx
├── (main)/
│   ├── pg-main-outlet.tsx
│   ├── products/
│   │   ├── pg-products.tsx
│   │   └── product-table-section/
│   │       └── pg-product-table-section.tsx
│   └── orders/
│       └── pg-orders.tsx
└── product-detail/
    └── pg-product-detail.tsx

라우터가 프레임과 화면의 진입 파일을 중첩으로 잇습니다.

route/app-routes.tsxtsx
import {PgOutlet} from "@/page/pg-outlet";
import {PgMainOutlet} from "@/page/(main)/pg-main-outlet";
import {PgOrders} from "@/page/(main)/orders/pg-orders";
import {PgProducts} from "@/page/(main)/products/pg-products";
import {PgProductDetail} from "@/page/product-detail/pg-product-detail";

export const AppRoutes = () => {
	return (
		<Routes>
			<Route path="/" element={<PgOutlet />}>
				<Route element={<PgMainOutlet />}>
					<Route path="products" element={<PgProducts />} />
					<Route path="orders" element={<PgOrders />} />
				</Route>
				<Route path="products/:productId" element={<PgProductDetail />} />
			</Route>
		</Routes>
	);
};

함께 보기

1.6 순수 계산을 감싸는 화면 전용 훅을 만들지 않습니다

실제 상태, 생명주기, 컨텍스트가 필요한 경우에만 React 훅을 사용합니다.

화면 전용 계산, 정규화, 전송 값 조립처럼 순수한 로직은 커스텀 훅으로 감싸지 않습니다.

flowchart LR
	q1{"상태, 컨텍스트, 훅 호출 순서를<br>실제로 캡슐화하는가?"} -- 아니요 --> r1("_function 의 순수 함수")
	q1 -- 예 --> r2("커스텀 훅")
대상처리
순수 계산use 접두사를 붙이지 않습니다
여러 쿼리를 합친 결과값을 렌더하는 섹션이 combine을 소유합니다. 여러 소유자가 같은 조합을 호출할 때만 _hook으로 옮깁니다
실제 커스텀 훅기능을 나타내는 use<Capability>로 이름 짓습니다. useData, useLogic처럼 구현 범주만 적지 않습니다
생명주기가 있는 로직분량을 줄이기 위한 추출은 허용하지 않습니다. 규칙 1.7를 따릅니다

함수 추출 여부는 TypeScript 규칙 3.3, 배치는 규칙 1.3를 따릅니다.

순수 지역 계산을 커스텀 훅으로 감쌉니다.

page/products/_hook/use-media-upload-payload.tsts
export const useMediaUploadPayload = (files: File[]) => {
	return files.map((file) => ({name: file.name, size: file.size}));
};
page/products/_pg-media-upload-panel.tsxtsx
import {useMediaUploadPayload} from "@/page/products/_hook/use-media-upload-payload";

export const PgMediaUploadPanel = (props: PgMediaUploadPanelProps) => {
	const mediaUploadPayload = useMediaUploadPayload(props.files);

	/**
	 * 업로드를 확정할 때 이미 만들어 둔 값을 보냄
	 */
	const handleSaveButtonClick: MouseEventHandler<HTMLButtonElement> = () => {
		mutationMediaSave.mutate({data: mediaUploadPayload});
	};

	return <UiButton onClick={handleSaveButtonClick}>저장</UiButton>;
};

순수 계산은 소유자의 _function 폴더에 두고 핸들러가 직접 부릅니다.

page/products/_function/to-media-upload-payload.tsts
/**
 * 업로드 파일 목록으로 저장 요청 본문을 조립
 */
export const toMediaUploadPayload = (files: File[]) => {
	return files.map((file) => ({name: file.name, size: file.size}));
};
page/products/_pg-media-upload-panel.tsxtsx
import {toMediaUploadPayload} from "@/page/products/_function/to-media-upload-payload";

export const PgMediaUploadPanel = (props: PgMediaUploadPanelProps) => {
	/**
	 * 업로드를 확정할 때만 정규화해서 보냄. 렌더 중에는 계산 안 함
	 */
	const handleSaveButtonClick: MouseEventHandler<HTMLButtonElement> = () => {
		mutationMediaSave.mutate({data: toMediaUploadPayload(props.files)});
	};

	return <UiButton onClick={handleSaveButtonClick}>저장</UiButton>;
};

함께 보기

1.7 라이브러리 생명주기는 소유 컴포넌트에서 관리합니다

외부 라이브러리의 생명주기와 실행 흐름을 소유 컴포넌트에서 확인할 수 있습니다.

외부 라이브러리의 인스턴스 생성, 크기 변경, 이벤트 구독, 정리는 하위 트리를 소유한 컴포넌트에 둡니다. 파일 분량을 줄이려고 생명주기를 커스텀 훅으로 옮기지 않습니다.

상황처리
한 소유자만 쓰는 생명주기해당 컴포넌트의 이펙트에 둡니다
여러 소유자가 같은 생명주기 계약을 실제로 호출함훅으로 추출합니다
파일이 길어짐생명주기 대신 도메인 계산을 _function으로 분리합니다
이펙트 정리, 재설치정리할 때 인스턴스 참조를 비우고 다시 설치할 때 새로 만듭니다. 상태에 남은 폐기된 인스턴스를 재사용하지 않습니다

파일 분량을 줄이려고 생명주기를 훅으로 옮깁니다.

component/widget/chart-card/wg-chart-card.tsxtsx
// 생성, resize, 정리가 _hook/use-chart-instance.ts로 빠져 이 파일에서는 실행 흐름이 안 보임
export const WgChartCard = (props: WgChartCardProps) => {
	const containerRef = useRef<HTMLDivElement>(null);
	const chart = useChartInstance(containerRef);

	/**
	 * option이 바뀌면 기존 인스턴스에 다시 반영
	 */
	useEffect(() => {
		chart?.setOption(props.option);
	}, [chart, props.option]);

	return <div ref={containerRef} className={clsx("wg_chartCard__canvas")} />;
};

생명주기를 소유 컴포넌트가 직접 가집니다.

component/widget/chart-card/wg-chart-card.tsxtsx
export const WgChartCard = (props: WgChartCardProps) => {
	const containerRef = useRef<HTMLDivElement>(null);
	const chartRef = useRef<ChartInstance | null>(null);

	/**
	 * 컨테이너 마운트 시 차트 인스턴스를 만들고 resize, dispose까지 소유
	 */
	useEffect(() => {
		if (!containerRef.current) {
			return;
		}

		// 외부 차트 라이브러리 인스턴스. 만든 컴포넌트가 resize와 dispose까지 책임
		const instance = mountChart(containerRef.current);
		const handleResize = () => {
			instance.resize();
		};

		window.addEventListener("resize", handleResize);
		chartRef.current = instance;

		return () => {
			chartRef.current = null;
			window.removeEventListener("resize", handleResize);
			instance.dispose();
		};
	}, []);

	/**
	 * option이 바뀌면 기존 인스턴스에 다시 반영
	 */
	useEffect(() => {
		chartRef.current?.setOption(props.option);
	}, [props.option]);

	return <div ref={containerRef} className={clsx("wg_chartCard__canvas")} />;
};

함께 보기