ListEmptyComponent vs 삼항연산자 — 무엇이 다른가FlashList, LegendList(그리고 RN 기본 FlatList)에는 ListEmptyComponent라는 prop이 있다. data가 비어 있을 때 자동으로 이 컴포넌트를 렌더링해준다. 그런데 다음처럼 삼항연산자로 직접 분기해도 똑같이 빈 상태를 보여줄 수 있다.
{type === 'SOME_TYPE' && dataList.length === 0 ? (
<EmptyStateComponent />
) : (
<CommonLegendList
data={dataList}
renderItem={renderItem}
keyExtractor={item => item.idx}
horizontal
// ...
/>
)}
그렇다면 ListEmptyComponent를 굳이 쓰는 이유가 무엇인지, 둘은 어떤 차이가 있는지 정리한다.
ListEmptyComponent는 어떤 구조인가내부적으로 리스트 컴포넌트는 대략 다음과 같이 동작한다. (개념적 의사코드)
function List({ data, renderItem, ListEmptyComponent, ListHeaderComponent, ListFooterComponent, ... }) {
const isEmpty = !data || data.length === 0;
return (
<ScrollContainer {...scrollProps}>
{ListHeaderComponent && renderElement(ListHeaderComponent)}
{isEmpty
? renderElement(ListEmptyComponent) // 비어 있으면 여기
: data.map((item, i) => renderItem({ item, index: i })) // 아니면 아이템들
}
{ListFooterComponent && renderElement(ListFooterComponent)}
</ScrollContainer>
);
}
핵심은 ListEmptyComponent가 스크롤 컨테이너 내부에, 헤더와 푸터 사이에 렌더링된다는 점이다. 즉 리스트의 레이아웃 컨텍스트를 그대로 유지한 채 빈 상태를 보여준다.
ListEmptyComponent는 세 가지 형태를 모두 받는다.
// 1) 컴포넌트 타입
ListEmptyComponent={EmptyState}
// 2) 엘리먼트
ListEmptyComponent={<EmptyState />}
// 3) 함수 (인라인)
ListEmptyComponent={() => <EmptyState />}
겉보기 결과는 같아도, 둘은 렌더 트리 구조가 다르다.
isEmpty ? <EmptyState /> : <List data={...} />
<List>가 아예 마운트되지 않는다.<List>가 새로 마운트된다. → 스크롤 위치, 측정 캐시, 내부 state가 초기화됨.<List>가 언마운트된다.ListEmptyComponent: 리스트 컴포넌트는 항상 살아있다<List data={dataList} ListEmptyComponent={EmptyState} renderItem={...} />
<List> 컴포넌트 자체는 계속 마운트 상태를 유지한다.ListEmptyComponent로 자식을 교체한다.이 차이가 실무에서 다음과 같은 결과로 이어진다.
ListEmptyComponent가 유리한 경우<List
data={dataList}
ListHeaderComponent={<SearchBar />} // 항상 보여야 함
ListEmptyComponent={<NoResults />}
ListFooterComponent={<Disclaimer />}
renderItem={renderItem}
/>
검색 결과 리스트에서 검색창(헤더)은 항상 떠 있어야 하고, 결과만 비어 있을 때 "결과 없음"을 보여주고 싶다면 ListEmptyComponent가 정답이다. 삼항연산자로 리스트 전체를 빼버리면 헤더의 검색창도 같이 사라진다.
<List
data={dataList}
ListEmptyComponent={<NoData />}
refreshControl={<RefreshControl refreshing={loading} onRefresh={refetch} />}
/>
빈 상태에서도 아래로 당겨 새로고침을 해야 하는 화면이라면, 스크롤 컨테이너가 살아있어야 한다. 삼항연산자로 리스트를 빼면 RefreshControl도 함께 사라져 새로고침을 못 한다.
검색어를 바꿀 때마다 결과가 비었다 찼다 한다면, 삼항연산자는 매번 리스트를 마운트/언마운트한다. ListEmptyComponent는 컨테이너를 유지하므로 전환이 더 매끄럽고, 마운트 비용이 없다.
contentContainerStyle, padding, 스크롤 영역의 중앙 정렬 등 리스트의 레이아웃 컨텍스트를 그대로 활용할 수 있다.
{type === 'SOME_TYPE' && dataList.length === 0 ? (
<ChashbackEmptyState /> // 가로 리스트와 무관한 별도 디자인
) : (
<CommonLegendList horizontal ... /> // 가로 스크롤 리스트
)}
리스트는 horizontal(가로 스크롤)인데, 빈 상태(ChashbackEmptyState)는 아마 세로로 꽉 찬 별도 디자인일 것이다. 이런 경우 ListEmptyComponent로 가로 스크롤 컨테이너 안에 욱여넣으면 오히려 레이아웃이 꼬인다. 빈 상태가 리스트의 레이아웃 컨텍스트를 공유할 필요가 없다면 삼항연산자가 더 깔끔하다.
빈 상태일 때 리스트의 스크롤, 측정, 가상화 로직이 도는 게 불필요하다면, 삼항연산자로 컴포넌트 자체를 빼는 게 더 명확하고 가볍다.
지금 코드처럼 type === 'SOME_TYPE' && dataList.length === 0처럼 "특정 타입일 때만, 그리고 비어 있을 때만" 같은 복합 조건이라면, ListEmptyComponent로는 표현하기 번거롭다. ListEmptyComponent는 "data가 비면"이라는 단일 조건에만 반응하기 때문이다. 복합 조건은 삼항연산자가 읽기 쉽다.
horizontal 리스트에서 주의할 점가로 리스트의 ListEmptyComponent는 가로 스크롤 컨테이너 안에 들어가기 때문에, 빈 상태 컴포넌트의 너비가 화면 전체로 잡히지 않을 수 있다. 가로 컨텐츠 영역은 콘텐츠 크기에 맞춰 좁아지려는 성질이 있어서, "화면 가운데에 크게 표시" 같은 디자인이 의도대로 안 나온다. 이걸 맞추려면 빈 컴포넌트에 명시적으로 width: SCREEN_WIDTH 등을 줘야 하는데, 그럴 바엔 삼항연산자로 빼는 게 더 직관적이다.
그래서 가로 리스트 + 리스트와 완전히 다른 빈 상태 디자인 조합에서는, 질문의 코드처럼 삼항연산자가 합리적인 선택이다.
| 기준 | ListEmptyComponent | 삼항연산자 |
|---|---|---|
| 리스트 컴포넌트 | 항상 마운트 유지 | 빈 상태일 때 언마운트 |
| 헤더/푸터 | 빈 상태에서도 유지 | 같이 사라짐 |
| Pull-to-Refresh | 빈 상태에서도 가능 | 같이 사라짐 |
| 스크롤 위치/측정 캐시 | 유지됨 | 초기화됨 |
| 빈 상태 레이아웃 | 리스트 컨텍스트 공유 | 완전히 독립적 |
| 복합 조건 분기 | 표현 번거로움 | 자연스러움 |
| 가로 리스트 + 다른 디자인 | 너비 이슈 발생 가능 | 깔끔함 |
ListEmptyComponent와 삼항연산자는 우열 관계가 아니라 용도가 다르다.
ListEmptyComponentListHeaderComponent 와ListFooterComponentListEmptyComponent와 형제 격인 prop이 ListHeaderComponent와 ListFooterComponent이다. 이름 그대로 리스트의 맨 위(헤더)와 맨 아래(푸터)에 고정으로 렌더링되는 컴포넌트다.
<List
data={poiList}
ListHeaderComponent={<SectionTitle />} // 리스트 맨 위
ListFooterComponent={<LoadMoreButton />} // 리스트 맨 아래
renderItem={renderItem}
/>
여기서 자주 나오는 질문이 "그냥 리스트 바깥에 헤더/푸터를 두면 안 되나?" 이다. 결과는 비슷해 보여도 둘은 동작이 다르다.
<View>
<SectionTitle /> {/* 리스트 바깥 헤더 */}
<List data={poiList} renderItem={renderItem} />
<LoadMoreButton /> {/* 리스트 바깥 푸터 */}
</View>
이 구조에서 헤더와 푸터는 리스트 스크롤 영역 바깥에 고정된다. 즉 리스트를 스크롤해도 헤더/푸터는 그 자리에 그대로 붙어 움직이지 않는다. (sticky한 고정 영역처럼 동작)
ListHeaderComponent / ListFooterComponent 방식<List
data={poiList}
ListHeaderComponent={<SectionTitle />}
ListFooterComponent={<LoadMoreButton />}
renderItem={renderItem}
/>
이 구조에서 헤더와 푸터는 리스트 스크롤 영역 안에 포함된다. 즉 리스트를 스크롤하면 헤더는 콘텐츠와 함께 위로 밀려 사라지고, 푸터는 맨 아래까지 스크롤해야 보인다.
| 기준 | 리스트 바깥에 선언 | ListHeader/FooterComponent |
|---|---|---|
| 스크롤 시 동작 | 화면에 고정 (안 움직임) | 콘텐츠와 함께 스크롤 |
| 스크롤 영역 | 헤더/푸터 제외한 리스트만 | 헤더+아이템+푸터 전체 |
| 가상화 영향 | 리스트와 무관 | 리스트 가상화 흐름에 포함 |
| 빈 상태일 때 | 항상 보임 (리스트와 독립) | 빈 상태에서도 보임 (리스트 내부) |
가장 큰 차이는 스크롤 동작이다. 한 문장으로 요약하면:
스크롤할 때 같이 움직여야 하면
ListHeaderComponent, 고정되어 있어야 하면 바깥에 선언.
ListHeaderComponent를 쓰는 경우ListFooterComponent가 적합.ListHeaderComponent/ListFooterComponent에 무거운 컴포넌트나 인라인 함수를 넣으면 주의가 필요하다.
// ❌ 매 렌더마다 새 함수/엘리먼트 생성 → 불필요한 재렌더
<List ListHeaderComponent={() => <HeavyHeader data={someProp} />} />
// ✅ 메모이즈하거나 안정적인 참조 사용
const renderHeader = useCallback(() => <HeavyHeader data={someProp} />, [someProp]);
<List ListHeaderComponent={renderHeader} />
리스트가 리렌더링될 때 헤더/푸터도 함께 평가되므로, 무거운 헤더라면 useCallback이나 React.memo로 안정적인 참조를 유지하는 게 좋다. 반면 바깥에 선언한 헤더는 리스트의 리렌더 흐름과 분리되어 있어 이 문제에서 비교적 자유롭다.