# Component Modeling — 상태·props를 닫힌 형태로 설계한다

적용: `src/**/*.tsx`와 상태를 만드는 모든 `.ts`. 새 상태/props를 추가하기 전에 읽는다.

## 설계 전 4질문 (순서 고정)

1. **없앨 수 있나?** — props로 밀어올리기·URL(nuqs)·Suspense/ErrorBoundary·서버 상태(TanStack Query)로 옮기면 이 상태는 사라지지 않는가? **0이 먼저다.**
2. 없앨 수 없다면 **discriminated union으로 닫혔나?** — boolean 조합 대신 `{ status: 'idle' } | { status: 'loading' } | { status: 'error'; error: E }`. tsc가 불가능 조합을 차단하게.
3. 이 새 prop/state가 **기존 것과 같은 개념 아닌가?** — 같은 개념이면 개별 추가가 아니라 하나의 union으로 합친다.
4. 분기가 **exhaustive한가?** — union 분기는 switch + `assertNever`. (switch-exhaustiveness는 if 체인을 못 본다 — union 분기는 switch로 쓰는 게 컨벤션)

## 술어

- **개별 정당의 함정**: `isLoading`+`isError`+`data`를 따로 두면 각각은 정당해 보여도 `isLoading && isError` 불가능 조합이 뚫린다. tsc는 "이 셋이 union이어야 한다"는 것까지는 못 잡는다 — 설계 판단이다.
- **discriminant는 한 층으로 평평하게**: 중첩 union(`status → error.kind`)은 읽는 모든 자리에 이중 체인 검사를 과세한다. 내부 분류는 최상위 discriminant로 승격하고, 파생값(안내 문구 등)은 상태를 만드는 쪽에서 확정해 담는다.
- **뷰 상태는 URL이 기본**: 필터·탭·검색어·정렬·페이지는 `useState`가 아니라 nuqs(searchParams). useState면 새로고침·공유·뒤로가기에서 증발한다. "화면 링크 = 화면 상태".
- **URL 상태의 파서·파생은 순수 모듈로 뺀다**: 파서 정의(`parseAsX`)와 그 값을 쓰는 파생·변환 함수를 컴포넌트 안에 두면 React를 띄워야만 검증된다. 슬라이스의 `model/`에 모아 두면 직렬화 규칙이 한 곳에 모이고 React 없이 테스트된다 — 화면이 늘어도 같은 규칙이 흔들리지 않는다 (골든: `pages/sample-table/model/postSearchParams.ts`).
- **전역·Context는 최후**: Context는 의존성을 인터페이스에서 숨긴다. props·URL·서버 상태로 명시적 흐름 먼저, 진짜 불가피할 때만 zustand.
- **props 폭이 넓어지면** (union은 깊이를 닫지 폭을 줄이지 않는다): 서버 데이터 내려꽂기→해당 컴포넌트에서 직접 쿼리 / drilling→composition(children) / 구체값 나열→객체·render prop / 책임 과다→쪼개기.
- **파생값은 렌더 중 지역 계산이 기본**. 승격 트리거: ①두 번째 컴포넌트가 같은 파생을 쓸 때 ②분기·계산이 얽혀 단독 테스트 가치가 생길 때.
- **reactive 라이브러리 입력은 안정 참조여야 한다**: react-table `data` 같은 headless 라이브러리 입력에 **매 렌더 새로 만든 배열**(`.filter()`/`.map()`/`.sort()` 결과 직접)을 넘기면, 라이브러리 내부가 매 렌더 리셋되며 **동기 무한 리렌더로 앱이 hang**한다. React "Maximum update depth"도 안 뜨고 콘솔 예외도 없어 게이트를 통과한다(사전 검증에서 목록 화면으로 실측). 정렬·필터는 순수 배열로 직접 처리하거나, 입력을 안정 참조로 만든 뒤 넘긴다. (show-screen 로드확인의 응답성 프로브가 이걸 잡는다)
