ListRow마다 유효하던 코드가 List 전체에서는 어긋났다

ListRow와 List의 책임 경계를 다시 긋고 무엇을 List가 소유할지 가르기
행을 다룬 앞 글들에서 자리를 나누고, 정보 구조로 Recipe를 가르고, 무엇을 받고 무엇을 하나의 계약으로 묶을지 정하고, 열지 않을 것을 골라냈다. 각 Recipe는 이제 자기가 받을 수 있는 조합만 받는다.
그런데 화면에 놓이는 것은 행 하나가 아니다.
아래 두 행은 각각 자기 Recipe의 계약을 지킨다.
<ListRowBasic
variant="default"
label="전체 알림"
/>
<ListRowMax
leading={{ type: 'image', src: providerLogo }}
title="A증권"
subtitle="CMA 계좌"
supporting={supporting}
rowAction={{
actionType: 'navigation',
href: '/products/a',
}}
/>행 하나씩 보면 유효하다. 필요한 값이 있고, 그 Recipe에서 허용한 prop만 넘겼다.
두 행을 같은 목록에 넣으면 달라진다.
// productRow는 위 ListRowMax에 넘긴 props다.
<List>
<ListRowBasic
variant="default"
label="전체 알림"
/>
<ListRowMax {...productRow} />
</List>행마다 높이와 Leading 크기와 정보 계층이 달라진다. 이 프로젝트의 Figma 명세에서 한 목록은 하나의 Row Recipe로 구성됐고, 서로 다른 높이와 정보 계층을 섞는 형태는 승인된 목록 조합에 없었다.
그런데 두 행은 각각 자기 계약을 지켰다. 개별 Row Recipe의 타입은 그 행에 전달된 props만 검증한다. 형제 행이 어떤 Recipe인지, 전체 순서가 어떻게 되는지는 그 계약 안에 없는 정보다.
1막에서는 요구가 두 층에서 왔다는 것까지만 적고 넘겼다. 무엇이 어느 층을 소유할지는 여러 행이 모인 목록을 다룰 때 정하기로 했고, 그 자리가 여기다.
개별 Row 계약만으로는 판정되지 않는 것을 모으면 이렇다.
- 같은 목록 안의 행이 전부 같은 Recipe인가
- 그 행이 첫 행인지 마지막 행인지
<li>를 렌더하려면 감싸는<ul>이나<ol>이 있어야 한다- item의 identity를 어디서 받아
<li>의 key로 쓸 것인가 - 행 사이에 다른 종류의 요소를 넣을 수 있는가
행 하나에서 허용할 조합을 정하는 일과, 행과 행 사이에서 허용할 조합을 정하는 일은 서로 다른 문제였다.
children으로 같은 Recipe만 허용할 수 있을까
React에서 가장 자연스러운 목록 API는 children을 받는 형태다.
<List>
<ListRowBasic variant="default" label="A" />
<ListRowBasic variant="default" label="B" />
</List>구조가 눈에 보이고 조건부 렌더링도 쉽다. 필요하면 행 사이에 제목이나 구분선을 넣을 수도 있다.
<List>
<ListRowBasic variant="default" label="A" />
<ListSectionTitle>
알림 설정
</ListSectionTitle>
<ListRowBasic variant="default" label="B" />
</List>이 유연성이 그대로 제약의 구멍이 된다. 서로 다른 Recipe를 한 목록에 넣는 코드도 똑같이 자연스럽게 작성된다.
<List>
<ListRowBasic variant="default" label="A" />
<ListRowMax {...productRow} />
</List>children: ReactNode 계약에서는 두 행이 모두 JSX element로 들어온다. List가 자식 element의 Recipe 정체성을 타입 수준에서 구분할 방법이 마땅치 않다.
children의 타입을 좁혀보는 방법도 있다.
type ChildrenListProps = {
children:
| ReactElement<ListRowBasicProps>
| readonly ReactElement<ListRowBasicProps>[];
};의도는 ListRowBasic만 받는 것이다. 하지만 이 타입이 보는 것은 element를 만든 컴포넌트의 출처가 아니라 props의 형태다. Leading에 승인된 아이콘만 받으려다 같은 벽을 만났던 그 성질이 목록에서 한 번 더 나온다. 구조적으로 호환되는 다른 컴포넌트가 그대로 들어오고, JSX 표현식은 구체적인 컴포넌트 정체성을 정적 타입에 남겨두지 않는다.
런타임에서 child.type을 확인해 경고할 수도 있다.
Children.forEach(children, (child) => {
if (
isValidElement(child) &&
child.type !== ListRowBasic
) {
console.warn(
'List에는 ListRowBasic만 사용할 수 있습니다.',
);
}
});개발 환경의 피드백으로는 쓸 수 있다. 다만 제품 개발자가 코드를 작성하는 시점의 타입 오류가 아니고, memo와 HOC와 어댑터 컴포넌트처럼 참조를 한 겹 감싸는 것들이 이 비교를 통과하지 못한다. Children이 보는 범위도 직접 자식까지라, 다른 컴포넌트 안에서 렌더된 행은 아예 검사에 걸리지 않는다.
시맨틱은 children으로도 소유할 수 있다. List가 <ul>을 그리고 각 child를 <li>로 감싸면 된다. children이 못 하는 것은 그 자리에 무엇이 들어오는지를 좁히는 일이고, 행 사이에 무엇을 넣을 수 있는지도 그대로 호출부의 결정으로 남는다.
한 목록에는 같은 Recipe의 행만 존재한다는 규칙을 타입으로 강제하려면 목록의 입력 형태 자체를 바꿔야 했다.
첫 원소로 타입을 고정하니 실제 데이터를 받을 수 없었다
행을 JSX element가 아니라 descriptor로 만든다면 배열의 첫 번째 원소에서 type을 추론할 수 있다.
declare function List<T>(props: {
rows: readonly [T, ...T[]];
}): ReactElement;[T, ...T[]]는 최소 한 개의 원소를 요구하는 튜플이다. 첫 번째 원소에서 T를 추론하고 나머지 원소를 같은 type으로 맞춘다.
아래의 basicRow와 maxRow는 각 Recipe에 대응하는 descriptor를 만드는 팩토리다. 어떤 Recipe로 그릴지를 값에 담고, 그리는 일은 List 안에서 그 값을 보고 한다.
type BasicRowSpec = {
kind: 'basic';
id: string;
label: string;
};<List
rows={[
basicRow({ id: '1', label: 'A' }),
basicRow({ id: '2', label: 'B' }),
]}
/>서로 다른 type을 섞으면 오류가 난다.
<List
rows={[
basicRow({ id: '1', label: 'A' }),
maxRow({ id: '2', title: 'B' }),
]}
/>오류는 나지만 maxRow 줄을 가리키지는 않는다. 배열 전체가 rows에 맞지 않는다는 형태로 붙는다. 이 글의 오류 메시지는 전부 TypeScript 7.0.2와 @types/react 19.2.18에서, strict와 jsx: react-jsx로 측정한 것이다.
error TS2322: Type '[BasicRow, MaxRow]' is not assignable
to type 'readonly [BasicRow, ...BasicRow[]]'.
Type at position 1 in source is not compatible with
type at position 1 in target.인라인 튜플에서는 원하던 제약이 만들어졌다.
문제는 실제 목록 데이터가 대부분 이런 형태로 만들어지지 않는다는 것이었다.
<List
rows={data.map(basicRow)}
/>map의 결과는 일반 배열이다. 첫 번째 원소가 반드시 존재하는 튜플이 아니다.
빈 목록도 받을 수 없었다.
<List rows={[]} />[T, ...T[]]가 최소 한 개를 요구하기 때문이다. 하지만 실제 목록은 서버 응답과 필터 결과와 권한과 검색 조건에 따라 비어 있을 수 있다.
타입 제약은 강했는데 제품 코드의 데이터 흐름과 맞지 않았다. 인라인 배열만 자연스럽게 받고, 서버 응답을 item으로 옮기는 가장 흔한 경로가 막힌다. 같은 type만 허용한다는 규칙을 지키면서도 일반 배열과 빈 상태를 받아야 했다.
행을 넣는 대신 Recipe 하나와 입력 배열을 받았다
그래서 List가 행을 그릴 Recipe 하나와 그 Recipe에 넘길 입력의 배열을 받도록 했다.
itemComponent가 하나이므로 List는 모든 item을 같은 Recipe로 렌더링한다. 제품 코드가 item마다 다른 Recipe를 고르는 통로가 없다.
여기서 정할 것이 둘이었다. items의 원소 타입은 itemComponent의 props에서 따라와야 하고, List가 <li>를 만드는 이상 key로 쓸 값도 같은 배열에서 받아야 한다. 두 값을 한 객체에 평평하게 섞으면 Recipe가 자기와 상관없는 값을 함께 받게 된다. 그래서 봉투를 하나 씌웠다.
type ListItem<P> = {
id: string;
row: P;
};
type RowPropsOf<C> =
C extends ComponentType<infer P> ? P : never;
type ListProps<C extends ComponentType<any>> = {
itemComponent: C;
items: readonly ListItem<RowPropsOf<C>>[];
};id는 봉투에 있고 Recipe가 받는 것은 row뿐이다. 목록이 쓰는 값과 행이 쓰는 값이 원소 하나 안에서도 갈라져 있다.
호출부는 이렇게 쓴다.
<List
itemComponent={ListRowRegular}
items={accounts.map((account) => ({
id: account.id,
row: {
label: account.name,
leading: {
type: 'icon',
name: 'user',
} as const,
},
}))}
/>바탕을 ComponentType<P>로 둔 데는 이유가 있었다. (props: P) => ReactElement처럼 좁게 선언하면 조건부로 null을 반환하는 행, memo로 감싼 행, forwardRef로 감싼 행이 모두 거부된다. 반환 타입이 ReactElement가 아니라 ReactNode이기 때문인데, 행을 숨기거나 재렌더를 줄이거나 DOM 노드를 참조하는 일은 실제 목록에서 필요했다.
구현에서는 한 가지를 더 처리해야 했다.
function List<C extends ComponentType<any>>({
itemComponent,
items,
}: ListProps<C>) {
const ItemComponent =
itemComponent as ComponentType<RowPropsOf<C>>;
return (
<ul>
{items.map(({ id, row }) => (
<li key={id}>
<ItemComponent {...row} />
</li>
))}
</ul>
);
}itemComponent를 받은 그대로 JSX 태그로 쓰면 컴파일되지 않는다. 타입 파라미터를 JSX 태그 자리에 두면 TypeScript가 속성 타입을 LibraryManagedAttributes<C, any>로 해석하는데, 아직 확정되지 않은 C에 대해서는 그 해석이 끝나지 않는다.
error TS2769: No overload matches this call.
The last overload gave the following error.
Type 'RowPropsOf<C>' is not assignable to type
'IntrinsicAttributes & LibraryManagedAttributes<C, any>'.그래서 구현 안에서 한 번 좁혀 받았다. 이 assertion은 공개 계약을 느슨하게 만들지 않는다. 호출부가 마주하는 ListProps<C>는 그대로이고 row의 형태도 여전히 itemComponent에서 따라온다. 봉투를 나눠둔 덕에 좁히는 대상도 Recipe props 하나로 끝났다.
id가 봉투에 있는 것은 List가 그 값을 만들 수 없기 때문이다. 배열 인덱스를 key로 쓰면 정렬이나 필터가 바뀔 때 React가 다른 item을 같은 것으로 본다. item의 identity는 제품 데이터가 제공하고, List는 그 값을 <li>의 key로 적용한다. 그리고 그 값은 거기서 끝나서 Recipe에는 넘어가지 않는다.
itemComponent={ListRowBasic}을 넘기면 row는 ListRowBasicProps를 만족해야 한다.
<List
itemComponent={ListRowBasic}
items={[
{
id: '1',
row: { variant: 'default', label: 'A' },
},
{
id: '2',
// TypeScript 오류:
// ListRowBasicProps에 없는 속성이다.
row: { title: 'B', supporting },
},
]}
/>map 결과도 그대로 받을 수 있고 빈 배열도 유효하다.
<List
itemComponent={ListRowBasic}
items={[]}
/>행 하나를 직접 렌더링할 때와 목록의 item으로 렌더링할 때 row에 들어가는 것이 같은 Recipe props라, 별도의 item 데이터 타입을 만들었다가 어긋나는 일도 줄었다.
as const가 붙어 있는 것은 이 호출 형태 때문이다. map 콜백이 만든 객체 리터럴에서는 leading.type이 string으로 넓어져서, 판별자가 리터럴 union인 계약을 그대로는 통과하지 못한다. 검증한 환경에서는 그 자리를 as const로 좁혔다.
구조적 타입 시스템이라는 점은 봉투 안에도 그대로 적용된다. 초과 프로퍼티 검사가 대상 타입에 직접 대는 객체 리터럴에만 강하게 걸리므로, 변수나 map 결과로 만든 row에 쓰이지 않는 속성이 남아 있어도 오류가 나지 않는다. 다만 어떤 Recipe로 그릴지는 itemComponent 하나로 고정된다.
목록 안의 행 type을 고정하는 책임은 item 하나하나가 아니라 List가 받은 renderer에 뒀다.
같은 props를 받는 컴포넌트는 무엇이든 renderer가 될 수 있었다
itemComponent를 ComponentType<P>로 받으면 다른 문제가 생긴다. 제품팀이 같은 props를 받는 별도 컴포넌트를 만들면 그대로 넘길 수 있다.
declare function MyCustomRow(
props: ListRowBasicProps,
): ReactElement;
<List
itemComponent={MyCustomRow}
items={rows}
/>
// ComponentType<P>라면 통과한다.TypeScript는 MyCustomRow가 승인된 Recipe인지 알지 못한다. props의 구조가 맞으면 호환되는 컴포넌트로 본다.
그래서 List가 받을 수 있는 Recipe 타입에 브랜드를 붙였다.
declare const rowComponent: unique symbol;
type ListRowComponent<P> = ComponentType<P> & {
readonly [rowComponent]: true;
};
type RowPropsOf<C> =
C extends ListRowComponent<infer P> ? P : never;
type ListProps<C extends ListRowComponent<any>> = {
itemComponent: C;
items: readonly ListItem<RowPropsOf<C>>[];
};디자인 시스템 안에서는 helper를 통해 Recipe를 만든다.
function defineListRow<P>(
component: ComponentType<P>,
): ListRowComponent<P> {
return component as ListRowComponent<P>;
}
const ListRowBasic =
defineListRow<ListRowBasicProps>(
function ListRowBasic(props) {
// BasicRow는 안쪽 조립 층에서
// 만든 구현이다.
return (
<BasicRow {...props} />
);
},
);브랜드는 ComponentType<P>와 교차하는 것이지 그것을 대체하지 않는다. memo로 감싼 Recipe도 defineListRow를 통과하고, 브랜드가 없는 컴포넌트는 형태가 같아도 거부된다.
<List
itemComponent={MyCustomRow}
items={rows}
// TypeScript 오류:
// MyCustomRow는
// ListRowComponent가 아니다.
/>이 브랜드는 보안 경계가 아니다. 제품 개발자가 assertion을 쓰면 우회할 수 있다.
<List
itemComponent={
MyCustomRow as ListRowComponent<
ListRowBasicProps
>
}
items={rows}
/>일반적인 타입 대입 경로에서는 브랜드가 붙은 공개 Recipe만 넘길 수 있고, 다른 컴포넌트를 쓰려면 그 자리에 assertion을 명시해야 한다. 승인되지 않은 renderer가 우연히 들어오는 경로를 없애는 것이 여기서 얻은 전부다.
지금 다시 보면 renderer를 아예 받지 않는 형태도 후보가 된다. 어떤 Recipe로 그릴지를 컴포넌트가 아니라 값으로 받고, List 안에서 그 값으로 renderer를 고르는 방식이다.
<List
recipe="basic"
items={basicItems}
/>승인되지 않은 renderer가 들어올 자리 자체가 없으니 브랜드도 필요하지 않다. 대신 List가 모든 Row Recipe를 직접 알게 되고, Recipe가 하나 늘 때마다 List의 union과 내부 분기를 함께 고쳐야 한다. 당시 고른 것은 renderer를 받는 쪽이었고 그 대가로 붙인 것이 브랜드다.
render prop도 같은 자리에서 갈린다. 일반 배열은 받을 수 있지만 제품 코드가 임의의 JSX를 반환하므로, 같은 Recipe만 존재하게 한다는 질문에는 답하지 못했다.
행 사이에 다른 것을 끼워 넣을 수 없게 됐다
itemComponent와 items를 고르면서 내준 것이 둘 있다. 오류가 붙는 자리가 멀어졌고, 행 사이가 막혔다. 세 가지 방식을 나란히 놓으면 이렇다.
| children | 제네릭 튜플 | itemComponent + items |
|
|---|---|---|---|
| 받는 것 | 행 element | 행 descriptor | Recipe 하나 + 입력 배열 |
| 서로 다른 Recipe 혼합 차단 | 어렵다 | 가능 | 가능 |
map으로 만든 배열 |
가능 | 어려움 | 가능 |
| 빈 목록 | 가능 | 어려움 | 가능 |
| 승인되지 않은 renderer | 어렵다 | 받지 않는다 | 브랜드로 막는다 |
<ul>과 <li> 소유 |
List가 감싸면 가능 | List | List |
| key로 쓸 identity | 호출부가 각 child에 붙인다 | descriptor의 id |
봉투의 id |
| 행 사이에 임의 요소 삽입 | 쉽다 | 어렵다 | 불가능 |
| 오류가 붙는 자리 | 행 하나의 prop 오류는 그 element에 | 배열 전체 | 인라인은 item, 변수는 배열 전체 |
children 열에 없는 것이 하나 있다. Recipe가 섞였다는 오류는 아예 나지 않는다. 타입에서 유효한 조합이기 때문이다.
마지막 행이 이 선택이 치른 비용이다. 행 하나를 직접 렌더링할 때는 잘못된 속성을 그 자리에서 가리킬 수 있었다.
items 안에서는 갈린다. 배열을 인라인으로 쓰고 row에 없는 속성을 넣으면 초과 프로퍼티 검사가 걸려서 오류가 그 속성을 정확히 가리킨다.
error TS2353: Object literal may only specify known
properties, and 'title' does not exist in type
'ListRowBasicProps'.배열을 변수나 map 결과로 만들면 검사가 걸리지 않고 배열 전체가 items에 맞는지로 판정된다. 오류는 items prop에 붙고 배열에서 item으로, item에서 row로, row에서 union 브랜치로, 브랜치에서 속성으로 여섯 겹을 내려간다. 그 과정에서 컴파일러가 설명에 고르는 브랜치가 의도한 쪽이 아닐 때도 있다.
error TS2322: Type '{ id: string; row: { variant: "default";
label: string; count: number; }; }[]' is not assignable to
type 'readonly ListItem<ListRowBasicProps>[]'.
...
Types of property 'count' are incompatible.
Type 'number' is not assignable to type 'undefined'.손해가 큰 쪽이 하필 제품 코드가 실제로 쓰는 형태다. 목록 데이터는 대개 서버 응답을 map으로 옮겨 만들고, 그때는 오류가 배열 전체로 올라간다. 중첩 descriptor를 고를 때 오류 위치가 가깝다는 것을 근거로 들었던 것을 생각하면 이 방향은 확실히 손해였다. 마지막 줄이 실제 원인을 정확히 말해준다는 점이 그나마 도움이 됐다.
다른 하나가 행 사이다. itemComponent와 items 구조에서는 행 사이에 임의의 제목이나 구분선을 넣을 수 없다.
<List
itemComponent={ListRowBasic}
items={items}
/>items는 전부 같은 Recipe에 넘길 입력이고 JSX element를 끼워 넣는 통로가 없다.
이건 예상하지 못한 부작용은 아니었다. 한 목록에 같은 종류의 행만 존재해야 한다면, 목록 중간에 정보 계층이 다른 요소를 임의로 넣는 것도 함께 제한해야 한다.
섹션이 필요한 화면에서는 List를 여러 개로 나눴다.
<section>
<h2>계정</h2>
<List
itemComponent={ListRowBasic}
items={accountItems}
/>
</section>
<section>
<h2>알림</h2>
<List
itemComponent={ListRowBasic}
items={notificationItems}
/>
</section>이렇게 하면 각 List는 같은 Recipe의 행만 소유하고, 섹션 제목과 그룹 구조는 화면이 명시적으로 소유한다.
반대로 행 하나만 필요한 화면까지 List로 감싸게 하지는 않았다.
<ListRowBasic
variant="default"
label="설정"
/>독립된 단일 행 Recipe는 목록 문맥을 가정하지 않는다. collection으로 다루는 화면에서만 상위 List가 목록 시맨틱을 만든다.
행이 자기가 몇 번째인지 알아야 하나
items를 받기로 하면서 List는 배열을 갖게 됐다. 배열을 가진 쪽은 인덱스도 갖는다. 그러면 다음 질문이 따라온다. 행에게 자기 순서를 알려줘야 하는가.
알려줄 이유는 있었다. 마지막 행 아래에는 구분선을 두지 않는다는 요구가 있었고, 목록 전체를 카드로 묶는 형태에서는 첫 행과 마지막 행의 모서리만 둥글다. 둘 다 행 하나의 모양을 바꾸는 규칙인데, 어느 행에 적용되는지는 순서가 정한다.
방법은 여럿이었다. List가 계산해 prop으로 넘길 수도 있고, Context로 내려줄 수도 있고, CSS로 마지막 자식만 고를 수도 있다.
// prop으로 넘긴다면 이런 모양이 된다.
<ListRow
{...row}
isFirst={index === 0}
isLast={index === items.length - 1}
/>셋 다 동작한다. 여기서 걸린 것은 기술적 가능 여부가 아니었다. 1막에서 세운 질문을 한 계층 위로 올려 물으면 답이 갈렸다. 이 결정에 필요한 정보를 누가 갖고 있는가.
인덱스는 List가 이미 갖고 있다. 배열을 받은 쪽이라 몇 개인지도 몇 번째인지도 따로 받을 것이 없다. 그 값은 행에 전달된 props 안에 없다.
그래서 isFirst나 isLast나 index를 행의 공개 prop으로 열지 않았다. 공개 prop은 호출부가 채우는 자리다. 열어두면 그 계산을 제품 코드가 하게 되고, 행이 그려지는 화면마다 다시 하게 된다. 앞 글의 기준이 그대로 걸린다. 그 결정이 행마다 다시 내려지면 안 된다.
틀린 값이 들어올 수도 있다. isLast가 true인 행이 목록 가운데 있어도 타입은 통과한다. 정렬을 바꾸거나 필터를 걸면 계산이 어긋나고, 어긋난 것은 렌더 결과를 봐야 보인다.
세 방법 중 무엇을 고르든 그 값을 정하는 정보는 순서를 보고 있는 쪽에 있다. Context로 내려도 값을 만드는 것은 List이고, CSS로 마지막 자식을 골라도 그 규칙을 쓰는 것은 목록 컨테이너다. 정보가 있는 자리와 계산이 놓인 자리를 붙여두면 행은 자기 순서를 모르는 채로 있어도 된다.
봉투 안의 row에는 순서에 대한 입력이 없다. 그 행이 무엇을 보여줄지와 무엇을 할지까지가 row이고, 몇 번째인지는 List가 렌더하면서 안다.
무엇을 계산해서 무엇을 바꿀지는 아직 정하지 않았다. 구분선을 어느 축에 둘지, 카드로 묶는 형태가 무엇을 바꾸는지는 목록 자체의 구조를 세운 다음의 일이다.
목록 시맨틱도 List의 몫이었다
앞에서 행 하나만 놓이는 화면을 List로 감싸지 않기로 했다. 그러면 행 Recipe가 무엇을 렌더할지가 걸린다.
행 Recipe가 언제나 <li>를 렌더하면 그 화면에서 <ul>이나 <ol> 없이 놓인 고아 <li>가 된다. 반대로 언제나 <div>를 렌더하면 여러 행이 모였을 때 목록이라는 정보가 사라지기 쉽다.
행에 전달된 props에는 자기가 목록 안에 있는지가 없다. 그래서 단일 행 Recipe는 목록 컨테이너를 가정하지 않고, <ul>과 <li>의 관계는 여러 items를 받은 List가 소유했다.
<ul>
{items.map(({ id, row }) => (
<li key={id}>
<ListRowBasic {...row} />
</li>
))}
</ul>정확한 DOM은 Recipe의 구조와 제품 요구에 따라 달라질 수 있다. 모든 목록이 같은 마크업이어야 한다는 뜻은 아니다. 여기서 정한 것은 그 결정을 개별 행이 각자 내리지 않는다는 것까지다.
ListRow는 행 내부를, List는 행 사이를 보장했다
ListRow Recipe는 행 하나의 정보 구조와 허용 prop을 제한한다. 그 계약이 검증하는 범위는 그 행에 전달된 props까지다.
실제 목록에는 그 바깥의 규칙이 있었다.
- 이 디자인 시스템의 한 List에는 하나의 Row Recipe만 사용한다
- 행 사이에 다른 종류의 요소가 끼어들지 않는다
- 첫 행과 마지막 행이 어느 것인지는 순서를 보는 쪽이 안다
- item의 identity는 제품 데이터가 제공하고 List가
<li>의 key로 적용한다 <ul>과<li>의 관계는 목록을 가진 쪽이 만든다
그래서 List는 children을 담는 컨테이너가 아니었다. Recipe 하나와 입력 배열을 받아 목록 안의 행 형태를 고정하고, 순서에서 나오는 규칙과 목록 시맨틱을 소유하는 컴포넌트였다.
두 계약은 연결돼 있지만 같은 판단을 반복하지 않는다. List는 label이 무엇이고 count가 어디에 붙는지 다시 해석하지 않고, ListRow는 형제 행과 전체 순서를 판단하지 않는다. items의 형태가 itemComponent에서 따라오는 것처럼 둘은 타입으로 이어져 있다. 이어져 있다는 것과 같은 결정을 양쪽에서 내린다는 것은 다르고, List가 아는 값을 안에서 계산해 넘기는 것과 그 계산을 공개 prop으로 열어 호출부에 떠넘기는 것도 다르다.
여기까지가 계약이다. 목록이 어떤 자리들로 이루어져 있는지, 구분선과 카드와 빈 상태를 어느 축에 둘지는 아직 답하지 않았다. 행에서 anatomy를 먼저 그리고 그 위에 축을 세웠던 것처럼 목록에도 같은 순서가 필요했다. 다음 글은 그 이야기다.