5. 컴포넌트 구조와 JSX
프롭스 계약은 컴포넌트 바로 위에 선언하고 값은 props.로 읽어 출처를 남깁니다. 본문은 훅, 핸들러, 이펙트, 반환 순으로 읽힙니다. JSX 안에는 동작을 숨기지 않고, 컴포넌트를 컴포넌트 안에서 정의하지 않습니다. ref는 실제 명령형 계약이 있을 때만 열고, Activity는 숨긴 상태와 DOM을 보존해야 할 때만 씁니다. Fragment와 조건부 렌더링은 형태를 하나로 고정합니다.
5.1 프롭스는 구조분해하지 않고 props로 읽습니다
값이 프롭스에서 왔다는 사실이 쓰는 자리마다 그대로 남습니다.
컴포넌트는 props 전체를 받고, 사용처에서 props.id처럼 읽습니다.
시그니처, 본문, 중첩 함수 어디에서도 구조분해하지 않습니다.
객체 출처를 유지하는 기본 기준은 TypeScript 규칙 4.3를 따릅니다.
| 상황 | 처리 |
|---|---|
{...props}로 그대로 전달함 | 구조분해가 아니며 출처도 유지됩니다. 허용 조건은 규칙 3.4을 따릅니다 |
| 선택 프롭에 기본값이 필요함 | TypeScript 규칙 5.1를 따릅니다. 사용처에서 값을 직접 비교하면 기본값이 필요하지 않습니다 |
본문 첫 줄에서 구조분해합니다.
const WgUserCard = (props: WgUserCardProps) => {
const { label, onSave } = props;
return <button type="button" onClick={onSave}>{label}</button>;
};
props로 읽어 출처를 남깁니다.
const WgUserCard = (props: WgUserCardProps) => {
return <button type="button" onClick={props.onSave}>{props.label}</button>;
};
시그니처에서 구조분해합니다.
const WgUserCard = ({ label, onSave }: WgUserCardProps) => {
return <button onClick={onSave}>{label}</button>;
};
함께 보기
- 6.4 파생값은 쓰는 자리 가까이에 둡니다
- 2.4 응답, 뮤테이션, 스토어는 JSX까지 원본 이름으로 읽습니다
- TypeScript 4.3 객체는 구조분해하지 않고 체인으로 읽습니다
5.2 컴포넌트 안에서 컴포넌트를 정의하지 않습니다
렌더마다 컴포넌트 타입을 다시 만들어 생기는 재마운트와 상태 초기화를 막습니다.
컴포넌트 본문 안에서 다른 컴포넌트를 새로 정의하지 않습니다. 부모가 다시 렌더될 때마다 자식 컴포넌트 타입이 새로 만들어져 재마운트, 포커스 초기화, 애니메이션 재시작, 이펙트 재실행이 생깁니다. 로컬에서 JSX 조각을 재사용하려면 독립 컴포넌트로 빼고 프롭스로 전달합니다.
렌더마다 새 컴포넌트 타입을 만듭니다.
export const WgUserCard = (props: WgUserCardProps) => {
const Avatar = () => {
return (
<img
className={clsx(
"wg_userCardAvatar__image",
props.theme === "dark" && "wg_userCardAvatar__image--dark",
)}
src={props.user.avatarUrl}
alt={props.user.name}
/>
);
};
return (
<section>
<Avatar />
</section>
);
};형제 파일로 뺀 컴포넌트를 부르고 값은 프롭스로 넘깁니다.
import {WgUserCardAvatar} from "@/component/widget/user-card/_wg-user-card-avatar";
export const WgUserCard = (props: WgUserCardProps) => {
return (
<section>
<WgUserCardAvatar src={props.user.avatarUrl} alt={props.user.name} theme={props.theme} />
</section>
);
};뺀 아바타는 소유자 폴더의 형제 파일에 둡니다.
/**
* 사용자 카드 아바타 프롭스
*/
export interface WgUserCardAvatarProps {
/**
* 어두운 배경에서 쓸지
*/
theme: "dark" | "light";
/**
* 아바타 이미지 주소
*/
src: string;
/**
* 아바타로 구분하는 사용자 이름
*/
alt: string;
}
export const WgUserCardAvatar = (props: WgUserCardAvatarProps) => {
return (
<img
className={clsx(
"wg_userCardAvatar__image",
props.theme === "dark" && "wg_userCardAvatar__image--dark",
)}
src={props.src}
alt={props.alt}
/>
);
};5.3 JSX 안 로직은 이름 붙인 핸들러로 뺍니다
부수효과, 분기, 비동기 흐름을 일반 코드 흐름에서 읽습니다.
JSX에는 이름 붙인 핸들러 참조만 넘깁니다. 분기, 비동기 호출, 여러 부수효과가 들어가면 핸들러로 분리합니다.
추가 인자를 넘기려고 onClick={() => handleX(id)} 같은 인라인 래퍼를 쓰지 않습니다.
그 자리는 규칙 9.2가 커링으로 정합니다.
분기와 비동기를 JSX 안에 숨깁니다.
<UiButton
onClick={() => {
if (!selectedProduct) {
return;
}
mutationProductRemove.mutate({params: {productId: selectedProduct.id}});
}}
>
삭제
</UiButton>
로직을 이름 붙인 핸들러로 뺍니다.
import type {MouseEventHandler} from "react";
/**
* 선택된 상품 삭제. 성공 뒤 이동은 mutation 콜백이 이어 감
*/
const handleRemoveProductButtonClick: MouseEventHandler<HTMLButtonElement> = (_event) => {
if (!selectedProduct) {
return;
}
mutationProductRemove.mutate({params: {productId: selectedProduct.id}});
};
<UiButton onClick={handleRemoveProductButtonClick}>삭제</UiButton>;
함께 보기
- 12.1 React의 주요 경계 선언에 JSDoc 주석을 붙입니다
- 9.3 사용자 액션은 이펙트가 아니라 핸들러에서 실행합니다
- 9.2 이벤트 외 추가 인자는 커링으로 넘깁니다
- TypeScript 3.3 보조 함수는 재사용되거나 함수 형태가 필수일 때만 이름을 붙입니다
5.4 직접 제어해야 할 때만 ref 프롭을 엽니다
사용하지 않는 명령형 계약이 공용 컴포넌트에 늘어나는 것을 막습니다.
ref는 사용처가 포커스, 스크롤, 측정 등을 직접 제어해야 할 때만 열고, 현재 사용처가 없으면 미리 공개하지 않습니다.
flowchart LR
q1{"사용처가 포커스, 스크롤,<br>측정을 직접 제어하는가?"} -- 예 --> q2{"명령 메서드 묶음을<br>공개하는가?"} -- 예 --> r3("useImperativeHandle 로<br>명령 계약 공개")
q1 -- 아니요 --> r1("ref 프롭을 열지 않기")
q2 -- 아니요 --> r2("DOM 요소를 가리키는 ref 프롭")| 조건 | 처리 |
|---|---|
| React 19 이상만 지원함 | forwardRef로 감싸지 않고 ref를 일반 프롭으로 받습니다 |
| React 18 이하도 지원함 | forwardRef 계약을 유지합니다. 지원 버전을 바꾸지 않고 일괄 전환하지 않습니다 |
useImperativeHandle로 명령 메서드를 공개함 | 계약 이름을 <Owner>Handle로 짓습니다 |
| DOM 요소를 직접 가리킴 | 별도 Handle 타입을 만들지 않습니다 |
| 외부 패키지 타입 제약으로 래퍼가 필요함 | TypeScript 규칙 6.5에 따라 이유를 남깁니다 |
ref 계약이 필요 없는 단순 화면 컴포넌트에도 습관적으로 ref를 노출합니다.
import type {Ref} from "react";
export interface UiStatusBadgeProps {
ref?: Ref<HTMLSpanElement>;
label: string;
}
export const UiStatusBadge = (props: UiStatusBadgeProps) => {
return <span ref={props.ref}>{props.label}</span>;
};
ref가 실제로 필요한 공개 API일 때만 React 19 방식으로 직접 받습니다.
import type {ChangeEventHandler, Ref} from "react";
/**
* 검색 입력 계약
*
* 결과 목록에서 검색어로 포커스를 되돌려야 해 `ref` 공개
* 보이는 라벨을 둘 자리가 없어 이름은 `aria-label`로만 제공
*/
export interface UiSearchInputProps {
/**
* 사용처가 포커스를 옮길 때 쓰는 참조
*/
ref?: Ref<HTMLInputElement>;
/**
* 스크린 리더가 읽을 이름
*/
label: string;
/**
* 입력값
*/
value: string;
/**
* 입력이 바뀔 때
*/
onChange: ChangeEventHandler<HTMLInputElement>;
}
export const UiSearchInput = (props: UiSearchInputProps) => {
return (
<input
ref={props.ref}
aria-label={props.label}
onChange={props.onChange}
value={props.value}
/>
);
};
ref가 실제 계약이 아닐 때는 일반 프롭만 둡니다.
/**
* 상태 배지 계약
*
* 밖에서 다룰 일이 없어 `ref` 비공개
*/
export interface UiStatusBadgeProps {
/**
* 배지에 표시할 상태 문구
*/
label: string;
}
export const UiStatusBadge = (props: UiStatusBadgeProps) => {
return <span>{props.label}</span>;
};
명령 메서드 묶음을 노출할 때만 useImperativeHandle과 <Owner>Handle 계약을 만듭니다.
/**
* 검색 입력이 밖에 여는 명령. 결과 목록이 포커스와 선택을 되돌릴 때 호출
*/
export interface UiSearchInputHandle {
/**
* 입력에 포커스를 두고 글자 전부 선택
*/
focusAndSelect: () => void;
}
export interface UiSearchInputProps {
/**
* 명령 계약. 사용처가 포커스를 되돌릴 때만 전달
*/
ref?: Ref<UiSearchInputHandle>;
}
export const UiSearchInput = (props: UiSearchInputProps) => {
const inputRef = useRef<HTMLInputElement>(null);
useImperativeHandle(props.ref, () => ({
focusAndSelect: () => {
inputRef.current?.focus();
inputRef.current?.select();
},
}));
return <input ref={inputRef} className={clsx("ui_searchInput__root")} aria-label="검색어" />;
};
함께 보기
5.5 상태를 보존해야 할 때만 Activity로 숨깁니다
상태 보존과 초기화 요구에 맞는 렌더 방식을 선택합니다.
렌더 방식 고르기
기본은 조건부 렌더링입니다.
flowchart LR
q1{"React 19.2<br>이상인가?"} -- 예 --> q2{"다시 보일 때 하위 트리<br>상태를 보존해야 하는가?"} -- 예 --> r2("Activity 로 숨기기")
q1 -- 아니요 --> r1("조건부 렌더링")
q2 -- 아니요 --> r1두 방식의 차이
| 비교 항목 | 조건부 렌더링으로 제거 | <Activity mode="hidden"> |
|---|---|---|
| 상태와 DOM | 버립니다 | 보존합니다 |
| 이펙트 | 정리하고 다시 마운트할 때 설치합니다 | 숨길 때 정리하고 다시 보일 때 설치합니다 |
| 숨긴 동안 렌더 | 없습니다 | 업데이트가 생기면 낮은 우선순위로 렌더합니다 |
| 접근성 트리 | 빠집니다 | display: none이 적용되어 빠집니다 |
확인할 조건
| 확인할 조건 | 처리 |
|---|---|
| 편집 취소 뒤 폼처럼 상태와 DOM을 초기화해야 함 | 조건부 렌더링을 유지합니다 |
| 구독 해제나 접근성이 목적임 | 두 방식의 차이가 아니므로 <Activity>를 고르는 근거로 삼지 않습니다 |
| 이펙트 정리와 재설치가 반복됨 | 상태가 남아 있어도 정상 동작하도록 작성합니다 |
| 동영상 재생 등 DOM 자체 동작을 멈춰야 함 | DOM 보존으로 계속될 수 있으므로 이펙트 정리에서 직접 멈춥니다 |
| 하위 트리가 무거움 | 숨겨도 업데이트 시 렌더되므로 습관적으로 보존하지 않습니다 |
초기화해야 할 폼을 숨겨 상태를 보존합니다.
// 편집을 취소했다가 다시 들어가면 지난 입력이 그대로 남음
return (
<Fragment>
<Activity mode={isEditing ? "visible" : "hidden"}>
<PgProductEditorForm />
</Activity>
<Activity mode={isEditing ? "hidden" : "visible"}>
<PgProductPreviewPane />
</Activity>
</Fragment>
);
폼 초기화가 필요하면 조건부 렌더링을 유지합니다.
// 편집을 취소하면 폼이 해제돼 다시 들어갈 때 빈 입력으로 시작
return (
<Fragment>
{isEditing && <PgProductEditorForm />}
{!isEditing && <PgProductPreviewPane />}
</Fragment>
);
다시 보여 줄 때 필요한 상태를 조건부 렌더링으로 잃습니다.
// page/products/_pg-product-sidebar.tsx: 접어 둔 노드와 스크롤 위치를 자기 상태로 보유
export const PgProductSidebar = () => {
const [expandedItems, setExpandedItems] = useState<string[]>([]);
return <UiTree expandedItems={expandedItems} onExpandedItemsChange={setExpandedItems} />;
};
// page/products/pg-products.tsx: 닫으면 해제돼 접어 둔 노드와 스크롤 위치가 사라짐
return isSidebarOpen && <PgProductSidebar />;
다시 보여 줄 때 하위 트리 상태를 보존해야 하는 경우에만 씁니다.
// page/products/_pg-product-sidebar.tsx: 접어 둔 노드와 스크롤 위치를 자기 상태로 보유
export const PgProductSidebar = () => {
const [expandedItems, setExpandedItems] = useState<string[]>([]);
return <UiTree expandedItems={expandedItems} onExpandedItemsChange={setExpandedItems} />;
};
// page/products/pg-products.tsx: 닫아도 상태와 DOM은 보존, 이펙트는 정리
return (
<Activity mode={isSidebarOpen ? "visible" : "hidden"}>
<PgProductSidebar />
</Activity>
);
함께 보기
5.6 프롭스 interface는 컴포넌트 바로 위에 선언합니다
계약과 구현을 일정한 순서로 읽을 수 있습니다.
프롭스는 <컴포넌트 이름>Props라는 interface로 선언하고 해당 컴포넌트 바로 위에 둡니다.
파일 상단에 프롭스 타입을 따로 모으지 않습니다.
| 상황 | 선언과 공개 위치 |
|---|---|
| 다른 파일에서 사용하는 컴포넌트 | 사용처가 계약을 참조하도록 프롭스 interface를 export합니다 |
| 같은 파일 안에서만 쓰는 화면 지역 컴포넌트 | 프롭스 interface를 export하지 않습니다 |
| 합성 부품의 프롭스 형태가 완전히 같음 | 공통 이름 하나로 공유합니다. 모두 {children}인 UiSectionRoot, UiSectionHeader, UiSectionFooter는 UiSectionProps를 씁니다 |
| 공유하는 부품이 한 파일에 있음 | 공유 interface를 첫 부품 위에 둡니다 |
| 공유하는 부품이 여러 파일에 나뉨 | 공유 interface를 소유자 _type 폴더에 둡니다 |
문서 주석, interface, 컴포넌트 순서로 붙여 계약을 먼저 읽게 하고, 합성 공개 부품도 같습니다.
공유 interface를 쓰는 부품의 개별 설명은 각 컴포넌트 위에 둡니다.
파일 위쪽에 타입을 모으고 내보내지 않습니다.
// component/ui/badge/ui-badge.tsx: 칩까지 한 파일에 두고 타입은 위쪽에 모음
interface UiBadgeProps {
label: string;
}
interface UiChipProps {
label: string;
}
export const UiBadge = (props: UiBadgeProps) => {
return <span className={clsx("ui_badge__root")}>{props.label}</span>;
};
export const UiChip = (props: UiChipProps) => {
return <span className={clsx("ui_chip__root")}>{props.label}</span>;
};
컴포넌트 파일마다 계약을 바로 위에 선언하고 내보냅니다.
/**
* 상태 배지 계약
*/
export interface UiBadgeProps {
/**
* 배지에 표시할 문구
*/
label: string;
}
export const UiBadge = (props: UiBadgeProps) => {
return <span className={clsx("ui_badge__root")}>{props.label}</span>;
};설명이 컴포넌트에 붙어 계약과 떨어집니다.
export interface UiPanelHeaderProps {
children: ReactNode;
}
/**
* 패널 헤더 부품
*/
export const UiPanelHeader = (props: UiPanelHeaderProps) => {
return <header className={clsx("ui_panel__header")}>{props.children}</header>;
};
설명, 계약, 선언을 붙여 둡니다.
/**
* 패널 헤더 부품
*
* 제목과 우측 동작 영역은 사용처가 직접 조립
*/
export interface UiPanelHeaderProps {
/**
* 헤더 줄에 늘어놓을 제목과 동작
*/
children: ReactNode;
}
export const UiPanelHeader = (props: UiPanelHeaderProps) => {
return <header className={clsx("ui_panel__header")}>{props.children}</header>;
};
함께 보기
5.7 <> 단축 문법 대신 Fragment를 그대로 씁니다
Fragment를 검색하고 변경 내역에서 식별하기 쉽습니다.
여러 요소를 감쌀 때는 react에서 가져온 <Fragment>를 쓰고 <>, </>는 쓰지 않습니다.
목록에서 key가 필요해져도 <Fragment key={…}> 형태를 유지합니다.
<>를 강제하는 biome의 style/useFragmentSyntax는 켜지 않습니다.
Fragment 단축 문법을 씁니다.
export const PgProductScreen = () => {
return (
<>
<PgProductFilterSection />
<PgProductTableSection />
</>
);
};
Fragment를 그대로 씁니다.
import {Fragment} from "react";
export const PgProductScreen = () => {
return (
<Fragment>
<PgProductFilterSection />
<PgProductTableSection />
</Fragment>
);
};
목록에서도 짧은 문법을 써서 key를 붙일 자리가 없습니다.
export const PgProductRows = (props: PgProductRowsProps) => {
return props.products.map((product) => (
<>
<PgProductRow product={product} />
<PgProductRowDivider />
</>
));
};
key가 필요해도 같은 형태를 씁니다.
import {Fragment} from "react";
export const PgProductRows = (props: PgProductRowsProps) => {
return props.products.map((product) => (
<Fragment key={product.id}>
<PgProductRow product={product} />
<PgProductRowDivider />
</Fragment>
));
};
5.8 JSX 분기는 조건을 각각 적습니다
각 요소 바로 앞에 표시 조건이 남아 화면 분기를 바로 읽을 수 있습니다.
JSX 분기는 각 요소 바로 앞에 표시 조건이 드러나도록 적습니다.
flowchart LR
q1{"조건에 따라 고르는 것이<br>JSX 요소인가?"} -- 예 --> q2{"컴포넌트 전체를<br>표시하지 않는가?"} -- 아니요 --> r3("분기마다 && 를 따로")
q1 -- 아니요 --> r1("값 하나는 삼항")
q2 -- 예 --> r2("이른 반환으로 null")| 표현할 내용 | 형태 |
|---|---|
| 조건에 따라 JSX 요소를 표시함 | 분기마다 &&를 따로 씁니다. 참, 거짓 요소를 삼항 하나로 묶지 않습니다 |
| 컴포넌트 전체를 표시하지 않음 | && 대신 이른 반환으로 null을 반환합니다 |
| 문자열, 숫자, 프롭 등 값 하나를 고름 | 이때만 삼항을 씁니다 |
서로 다른 분기는 같은 판별값을 기준으로 조건이 겹치지 않게 적습니다.
숨긴 하위 트리의 상태를 보존해야 하면 규칙 5.5를 따릅니다.
&& 왼쪽에는 숫자를 두지 않고 길이, 개수는 비교식으로 바꿉니다.
거짓으로 평가되는 0과 NaN도 화면에 그대로 렌더됩니다.
JSX 두 분기를 삼항 하나로 묶습니다.
return (
<section>
{props.view === "chart" ? <PgChart /> : <PgTable />}
</section>
);
각 JSX 요소 앞에 표시 조건을 둡니다.
return (
<section>
{props.view === "chart" && <PgChart />}
{props.view === "table" && <PgTable />}
</section>
);
&& 왼쪽에 숫자를 둬서 0이 렌더됩니다.
return <section>{selectedRows.length && <PgProductBulkActionBar />}</section>;
&& 왼쪽에 불리언 비교식을 씁니다.
return <section>{selectedRows.length > 0 && <PgProductBulkActionBar selectedRows={selectedRows} />}</section>;
컴포넌트 전체를 표시하지 않을 때 &&를 씁니다.
const PgProductPanel = (props: PgProductPanelProps) => {
return props.isVisible && <section className={clsx("pg_productPanel__root")}>{props.children}</section>;
};
컴포넌트 전체를 표시하지 않으면 이른 반환으로 끝냅니다.
const PgProductPanel = (props: PgProductPanelProps) => {
if (!props.isVisible) {
return null;
}
return <section className={clsx("pg_productPanel__root")}>{props.children}</section>;
};
프롭 값 하나는 삼항으로 고릅니다.
return <UiBadge tone={props.isSelected ? "accent" : "neutral"} />;
5.9 본문은 훅, 핸들러, 이펙트, 반환 순으로 배치합니다
컴포넌트마다 훅, 핸들러, 이펙트를 같은 순서로 찾을 수 있습니다.
컴포넌트 본문은 아래 네 구획 순서로 작성하고, 렌더 중에 읽는 값은 사용 위치보다 위에서 선언합니다.
flowchart TD
s1("훅<br>라우터, 스토어, 쿼리, 컨텍스트<br>커스텀 훅, useState, useRef") --> s2("핸들러<br>handle* 함수")
s2 --> s3("이펙트<br>useEffect, useLayoutEffect")
s3 --> s4("반환<br>이른 반환과 JSX")이펙트의 인자와 의존성 배열은 해당 줄에서 평가되므로, 이펙트를 마지막 훅으로 두어 앞선 선언을 참조합니다. 이른 반환은 모든 훅 뒤에 두어 렌더마다 훅 호출 개수를 유지합니다. 구획 안에서는 선언 뒤에 참조한다는 조건만 지키고, 파생값은 규칙 6.4에 따라 사용처에서 계산합니다.
같은 종류가 흩어지고 이펙트가 아래 선언을 의존성으로 참조합니다.
export const PgOrderToolbar = () => {
// selectedIds가 아직 초기화 전이라 의존성 배열을 평가하는 이 줄에서 오류
useEffect(() => {
document.title = `주문 ${selectedIds.length}건 선택`;
}, [selectedIds]);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const handleClearButtonClick: MouseEventHandler<HTMLButtonElement> = () => {
setSelectedIds([]);
};
const [isPanelOpen, setIsPanelOpen] = useState(false);
const handlePanelOpenButtonClick: MouseEventHandler<HTMLButtonElement> = () => {
setIsPanelOpen(true);
};
return (
<section className={clsx("pg_orderToolbar__root")}>
<UiButton onClick={handleClearButtonClick}>비우기</UiButton>
<UiButton onClick={handlePanelOpenButtonClick}>필터</UiButton>
{isPanelOpen && <PgOrderFilterPanel />}
</section>
);
};
네 구획이 순서대로 놓입니다.
export const PgOrderToolbar = () => {
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [isPanelOpen, setIsPanelOpen] = useState(false);
/**
* 비우기는 선택만 지우고 패널은 유지
*/
const handleClearButtonClick: MouseEventHandler<HTMLButtonElement> = () => {
setSelectedIds([]);
};
/**
* 필터 패널 열기
*/
const handlePanelOpenButtonClick: MouseEventHandler<HTMLButtonElement> = () => {
setIsPanelOpen(true);
};
useEffect(() => {
document.title = `주문 ${selectedIds.length}건 선택`;
}, [selectedIds]);
return (
<section className={clsx("pg_orderToolbar__root")}>
<UiButton onClick={handleClearButtonClick}>비우기</UiButton>
<UiButton onClick={handlePanelOpenButtonClick}>필터</UiButton>
{isPanelOpen && <PgOrderFilterPanel />}
</section>
);
};
함께 보기
5.10 widget, ui 안의 부품은 런타임 경계가 있을 때만 파일로 나눕니다
자체 책임이 있는 부품만 분리해 소유자 안 파일 수와 구조를 읽기 쉽게 유지합니다.
widget과 ui 컴포넌트 안의 부품은 아래 책임 중 하나를 직접 소유할 때만 파일로 뗍니다.
단순 래퍼, className 묶음, 들여쓰기 감소, 긴 파일은 분리 근거가 아닙니다.
분리 근거가 되는 책임
flowchart LR
q1{"런타임 책임을<br>직접 소유하는가?"} -- 아니요 --> q2{"재사용하거나<br>조립에 공개하는가?"} -- 아니요 --> r2("진입 파일에 유지")
q1 -- 예 --> r1("파일로 분리")
q2 -- 예 --> r1| 책임 | 예 |
|---|---|
| 비동기 | Suspense, 스켈레톤, 로딩, 오류, 빈 상태 |
| 상태와 프로바이더 | 지역 상태, 이펙트 동기화, 폼 프로바이더, 컨텍스트, 범위를 좁힌 스토어 |
| 상호작용 | 팝오버, 모달, 선택, 인라인 편집, 드래그, 펼치는 트리 |
| 라이브러리와 성능 | 외부 라이브러리 생명주기 어댑터, 가상 스크롤, 트랜지션, 지연 값 |
widget, ui 전용 책임
아래 두 책임은 widget, ui에만 적용합니다.
| 책임 | 예 |
|---|---|
| 재사용 | 같은 소유자 안 두 곳 이상이 같은 부품을 렌더 |
| 조립 | 사용처가 넣고 빼거나 스타일을 바꾸도록 공개하는 합성 부품 |
컨텍스트를 읽어 분기만 하는 부품은 진입 파일에 남깁니다. 라우트 진입 파일의 섹션은 규칙 6.3가 같은 기준으로 정합니다.
컨텍스트를 읽어 분기만 하는 래퍼를 파일로 뗍니다.
// component/widget/chatbot/_wg-chat-content.tsx: 어느 화면을 그릴지 고르기만 하고 상태는 소유 안 함
export const WgChatContent = () => {
const chat = useChatContext();
return (
<Fragment>
{chat.isEmpty && <p className={clsx("wg_chatbot__empty")}>{chat.emptyMessage}</p>}
{!chat.isEmpty && <WgChatMessages messages={chat.messages} />}
</Fragment>
);
};
분기는 진입 파일에 남기고 상태를 소유한 부품만 뗍니다.
/**
* 챗봇 위젯 진입. 대화 목록과 입력 폼 조립
*/
export const WgChatbot = () => {
const chat = useChatContext();
return (
<section className={clsx("wg_chatbot__root")}>
{/**
* 대화 목록. 비어 있으면 안내 문구 표시
*/}
{chat.isEmpty && <p className={clsx("wg_chatbot__empty")}>{chat.emptyMessage}</p>}
{!chat.isEmpty && <WgChatMessages messages={chat.messages} />}
{/**
* 입력 폼. 전송 중 상태와 폼 프로바이더를 소유해 _wg-chat-composer.tsx로 분리
*/}
<WgChatComposer onSubmit={chat.send} />
</section>
);
};함께 보기