Navigation

6. 화면 파일 규율

라우트 진입 파일에 화면 흐름을 드러내고, 상태나 비동기를 직접 소유한 섹션만 추출합니다. 파생값은 사용처에서 계산하고 필요가 확인되기 전에 분리하지 않습니다.

6.1 라우트 진입 파일은 화면 흐름 조립에 집중합니다

진입 파일만 봐도 화면 흐름을 따라갈 수 있습니다.

라우트 진입은 화면 흐름을 조립하고, 데이터와 동작은 사용하는 컴포넌트가 소유합니다.

라우트 진입의 책임

라우트 진입의 책임범위
섹션 조립과 Suspense 경계경계 수는 규칙 7.1를 따릅니다
화면 전체 이펙트화면 전체에 걸친 동기화를 소유합니다
라우트 사이의 이동 흐름 결정useNavigate 호출은 실제 동작을 일으키는 컴포넌트가 맡습니다

데이터와 동작의 소유자

데이터, 동작소유 위치
서버 응답데이터를 렌더하는 컴포넌트가 같은 key로 직접 읽습니다
뮤테이션동작을 일으키는 컴포넌트가 소유합니다
라우트 params, search 파라미터사용처에서 useParams와 URL 파서 묶음으로 읽고 씁니다
여러 응답을 합친 파생값값을 렌더하는 섹션이 인라인 combine을 소유합니다

독립 섹션이 없는 작은 화면은 진입 컴포넌트가 데이터 소유자를 겸할 수 있습니다. 이때만 쿼리를 직접 호출하고 경계는 상위 레이아웃에 두며, 경계만을 위한 빈 섹션은 만들지 않습니다.

같은 데이터를 여러 섹션이 읽을 때

같은 데이터가 여러 섹션에 필요해도 프롭으로 내리지 않고 각 섹션이 같은 key로 읽습니다. 요청이 늘면 staleTime, refetchOnMount, 실제 키를 먼저 확인합니다. 부모의 대기로 자식 요청이 늦어지면 대기 전에 실행되는 소유자에서 같은 key를 usePrefetchQuery로 먼저 요청합니다.

라우트 진입이 쿼리를 대신 읽어 프롭으로 내립니다.

page/products/pg-products.tsxtsx
export const PgProducts = () => {
	const [urlParams] = useQueryStates(productUrlParsers);
	const responseProductListSuspense = useProductListSuspense({page: urlParams.page});

	return (
		<Fragment>
			<PgProductFilterSection />
			<PgProductListSection products={responseProductListSuspense.data.items} />
		</Fragment>
	);
};

라우트 진입은 섹션 조립과 경계만 맡습니다.

page/products/pg-products.tsxtsx
export const PgProducts = () => {
	return (
		<Fragment>
			<PgProductFilterSection />
			<Suspense fallback={<UiLoadingFallback ariaLabel="product 목록을 불러오는 중" />}>
				<PgProductListSection />
			</Suspense>
		</Fragment>
	);
};

섹션이 자신의 쿼리 키로 데이터를 읽고 저장까지 소유합니다.

page/products/_pg-product-list-section.tsxtsx
export const PgProductListSection = () => {
	const [urlParams, setUrlParams] = useQueryStates(productUrlParsers);

	/**
	 * 표에 그릴 상품을 URL의 page로 조회
	 */
	const responseProductListSuspense = useProductListSuspense(
		{page: urlParams.page},
		{query: {select: (response) => ({items: response.data.items})}},
	);

	const queryClient = useQueryClient();

	/**
	 * 저장 성공 시 목록을 다시 읽고 첫 페이지로 이동. 새 상품이 맨 앞
	 */
	const mutationProductSave = useProductSave({
		mutation: {
			onSuccess: () => {
				void queryClient.invalidateQueries({queryKey: productListQueryKey()});
				void setUrlParams({page: 1});
			},
		},
	});

	/**
	 * 폼 값을 전송 형태로 바꿔 저장만 호출. 저장 뒤 흐름은 mutation 콜백이 이어 감
	 */
	const handleProductSave: UiTableProps["onSave"] = (formValues) => {
		mutationProductSave.mutate({data: toProductSaveRequest(formValues)});
	};

	return <UiTable rows={responseProductListSuspense.data.items} onSave={handleProductSave} />;
};

함께 보기

6.2 화면 코드를 미리 추상화하지 않습니다

추측에 따른 추출을 줄이고 실제 재사용 경계에 맞춰 코드를 배치합니다.

반복이 보인다는 이유만으로 공용 훅, 컴포넌트, 보조 함수를 추출하지 않습니다. 먼저 흐름을 같은 파일에서 읽을 수 있도록 정리합니다.

먼저 시도할 방법

먼저 시도할 방법유지할 위치
단계 변수, 섹션 주석, 내부 블록으로 정리한 함수 안
화면 흐름이 보이도록 JSX 정리화면 지역 JSX
작은 변환, href 조립, 기본값 처리사용처

한 컴포넌트, 핸들러, 쿼리 select만 쓰는 보조 함수를 별도 모듈에 쌓지 않습니다. 이름을 붙이기 좋다는 이유만으로 흐름을 여러 파일에 나누지 않습니다.

추출 허용 경계

추출 대상허용 경계
컴포넌트규칙 6.3
함수TypeScript 규칙 3.3
훅규칙 1.6

컴포넌트 하나만 쓰는 단계 보조 함수를 보조 모듈에 남깁니다.

const toEditHref = ({editHrefBase, row}: {editHrefBase: string; row: ProductRow}) =>
	`${editHrefBase}${row.id}/`;

const toProductRows = (response: ProductListResponse) =>
	response.data.items.map((product) => ({id: product.id, name: product.name}));

export const PgProductTable = (props: PgProductTableProps) => {
	const responseProductListSuspense = useProductListSuspense({}, {query: {select: toProductRows}});

	return responseProductListSuspense.data.map((row) => (
		<a href={toEditHref({editHrefBase: props.editHrefBase, row})} key={row.id}>
			{row.name}
		</a>
	));
};

작은 쿼리 가공과 href 조립은 사용처에 둡니다.

export const PgProductTable = (props: PgProductTableProps) => {
	/**
	 * 링크에 필요한 두 필드만 남김. 표는 응답 구조를 모름
	 */
	const responseProductListSuspense = useProductListSuspense(
		{},
		{query: {select: (response) => response.data.items.map((product) => ({id: product.id, name: product.name}))}},
	);

	return responseProductListSuspense.data.map((row) => (
		<a href={`${props.editHrefBase}${row.id}/`} key={row.id}>
			{row.name}
		</a>
	));
};

사용처가 한 화면뿐인데 공용 훅으로 먼저 빼냅니다.

page/products/pg-products.tsxtsx
// useProductFilterForm은 두 useState를 돌려주기만 하고 이 화면 하나만 호출
import {useProductFilterForm} from "@/page/products/_hook/use-product-filter-form";

export const PgProducts = () => {
	const productFilterForm = useProductFilterForm();

	return <PgProductFilterSection keyword={productFilterForm.keyword} />;
};

한 화면만 쓰는 동안은 화면 안에 그대로 둡니다.

page/products/pg-products.tsxtsx
export const PgProducts = () => {
	const [keyword, setKeyword] = useState("");
	const [categoryId, setCategoryId] = useState<string>();

	return (
		<PgProductFilterSection
			keyword={keyword}
			categoryId={categoryId}
			onKeywordChange={setKeyword}
			onCategoryIdChange={setCategoryId}
		/>
	);
};

두 화면이 같은 흐름을 부르게 된 뒤에 공용화합니다.

/**
 * 등록 화면과 수정 화면이 저장 실패를 같은 문구로 보여야 해서 한곳에 묶음
 * 두 화면 모두 이 훅을 부르므로 한쪽만 고치면 표시가 갈림
 */
export const useProductEditor = () => {
	const form = useForm<ProductEditorFormValues>();
	const [submitErrorMessage, setSubmitErrorMessage] = useState<string | null>(null);

	/**
	 * 저장 실패 문구를 이 훅이 함께 들고 있어야 해서 여기서 받음
	 */
	const mutationProductSave = useProductSave({
		mutation: {
			onError: (error) => {
				setSubmitErrorMessage(toSubmitErrorMessage(error));
			},
		},
	});

	return {form, mutationProductSave, submitErrorMessage};
};

여러 보조 함수 대신 한 함수 안에서 단계별로 정리합니다.

/**
 * 화면이 보낼 값 조립을 한 함수 안에서 마무리. 단계마다 보조 함수를 만들지 않음
 */
export const toProductSaveRequest = (formValues: ProductFormValues) => {
	// 1. 사용자가 넣은 앞뒤 공백을 서버로 보내기 전에 정리
	const name = formValues.name.trim();
	const description = formValues.description.trim();

	// 2. API가 받는 페이로드 형태로 조립
	return {categoryId: formValues.categoryId, description, name};
};

함께 보기

6.3 런타임 경계가 있는 섹션만 화면 지역 컴포넌트로 추출합니다

화면 흐름을 유지하면서 자체 책임이 있는 섹션만 분리합니다.

라우트 진입의 지역 컴포넌트는 아래 책임 중 하나를 직접 소유할 때만 추출합니다. 단순 래퍼, className 묶음, 들여쓰기 감소는 추출 근거가 아닙니다.

책임예
비동기Suspense, 스켈레톤, 로딩, 오류, 빈 상태
상태와 프로바이더지역 상태, 이펙트 동기화, 폼 프로바이더, 컨텍스트, 범위를 좁힌 스토어
상호작용팝오버, 모달, 선택, 인라인 편집, 드래그, 펼치는 트리
라이브러리와 성능외부 라이브러리 생명주기 어댑터, 가상 스크롤, 트랜지션, 지연 값

추출한 파일의 배치는 규칙 1.3를 따릅니다. 진입 파일의 JSX에 나타나지 않는 섹션을 다른 섹션 파일 안에서 렌더하면 과하게 나눈 것입니다.

감싸기만 하는 래퍼를 섹션으로 추출합니다.

// page/products/_pg-product-sidebar-panel.tsx: 감싸기만 하고 자기 책임 없음
export const PgProductSidebarPanel = (props: PgProductSidebarPanelProps) => {
	return <section className={clsx("pg_products__sidebar")}>{props.children}</section>;
};

데이터, 상태, 상호작용을 소유한 섹션만 추출하고 자신의 쿼리 키로 읽습니다.

page/products/_pg-product-tree-section.tsxtsx
export const PgProductTreeSection = () => {
	const [urlParams, setUrlParams] = useQueryStates(productUrlParsers);
	const [expandedItems, setExpandedItems] = useState<string[]>([]);

	/**
	 * 사이드바가 그릴 분류 노드만 남김. 트리 펼침 상태는 이 섹션이 따로 보유
	 */
	const responseProductTreeSuspense = useProductTreeSuspense(
		{},
		{query: {select: (response) => ({categoryNodes: response.data.categoryNodes.map(toTreeData)})}},
	);

	/**
	 * 고른 분류를 URL에 기록. 새로 고침해도 같은 화면
	 */
	const handleTreeSelectedItemsChange: UiTreeProps["onSelectedItemsChange"] = (itemId) => {
		if (itemId === null) {
			return;
		}

		void setUrlParams({categoryId: itemId});
	};

	return (
		<section className={clsx("pg_products__sidebar")}>
			{responseProductTreeSuspense.data.categoryNodes.length > 0 && (
				<UiTree
					items={responseProductTreeSuspense.data.categoryNodes}
					expandedItems={expandedItems}
					selectedItems={urlParams.categoryId}
					onExpandedItemsChange={setExpandedItems}
					onSelectedItemsChange={handleTreeSelectedItemsChange}
				/>
			)}
			{responseProductTreeSuspense.data.categoryNodes.length === 0 && <UiEmpty description="분류가 없습니다" />}
		</section>
	);
};

6.4 파생값은 쓰는 자리 가까이에 둡니다

파생값의 출처를 유지하고 화면 상단의 별칭과 준비 코드를 줄입니다.

useState와 프롭스에서 나온 조건 플래그, 표시값은 사용처에서 계산합니다. 화면 상단에 준비 코드로 모으지 않고, 훅 인자, JSX, 이펙트 내부의 좁은 스코프에 둡니다. 이 규칙은 소유 파일 안에서 계산 위치만 정합니다. 응답과 스토어의 출처는 규칙 2.4, 이름을 붙일지는 TypeScript 규칙 3.8가 정합니다.

쓰는 자리에서 먼 화면 상단에 플래그와 표시값을 쌓습니다.

export const PgProductTableSection = () => {
	const responseProductListSuspense = useProductListSuspense();
	const [selectedRows, setSelectedRows] = useState<ProductRow[]>([]);

	/**
	 * 고른 행은 이 섹션 안에만 두고 search 파라미터로 올리지 않음
	 */
	const handleTableRowSelect: UiTableProps["onRowSelect"] = (rows) => {
		setSelectedRows(rows);
	};

	// 아래 둘은 이름만 남기고 무엇에서 나온 값인지 지움
	const hasSelectedRows = selectedRows.length > 0;
	const bulkActionLabel = `${selectedRows.length}건 삭제`;

	return (
		<Fragment>
			<UiTable rows={responseProductListSuspense.data.items} onRowSelect={handleTableRowSelect} />

			{hasSelectedRows && <PgProductBulkActionBar label={bulkActionLabel} />}
		</Fragment>
	);
};

선언을 그대로 두고 쓰는 자리에서 계산합니다.

export const PgProductTableSection = () => {
	const responseProductListSuspense = useProductListSuspense();
	const [selectedRows, setSelectedRows] = useState<ProductRow[]>([]);

	/**
	 * 고른 행은 이 섹션 안에만 두고 search 파라미터로 올리지 않음
	 */
	const handleTableRowSelect: UiTableProps<ProductRow>["onRowSelect"] = (rows) => {
		setSelectedRows(rows);
	};

	return (
		<Fragment>
			<UiTable rows={responseProductListSuspense.data.items} onRowSelect={handleTableRowSelect} />

			{selectedRows.length > 0 && (
				<PgProductBulkActionBar label={`${selectedRows.length}건 삭제`} />
			)}
		</Fragment>
	);
};

함께 보기