35줄 커스텀 스토어부터 400개 필드 AppState까지 — Redux, Zustand 없이 모든 컴포넌트를 동기화하는 방법.
Claude Code의 상태 관리는 외부 라이브러리 없이 3개 레이어로 구성됩니다:
createStore<T> — 35줄의 커스텀 스토어. 구독, 업데이트, 사이드 이펙트를 처리하는 최소 단위AppState + AppStateStore — 약 400개 필드를 가진 애플리케이션 전체 상태와 그 스토어AppStateProvider + hooks — React 컴포넌트가 상태를 구독하고 업데이트하는 인터페이스이 구조를 보완하는 두 가지 핵심 메커니즘:
onChangeAppState — 모든 상태 전환에서 실행되는 단일 관찰자selectors.ts — 스토어에서 필요한 값을 도출하는 순수 함수 모음전체 상태 관리의 기반이 되는 createStore<T>는 놀라울 정도로 간결합니다:
// createStore — 35줄 커스텀 스토어 (간소화)
function createStore<T>(
initialState: T,
onChange?: (prev: T, next: T) => void
) {
let state = initialState
const listeners = new Set<() => void>()
return {
getState(): T { return state },
setState(updater: (prev: T) => T) {
const next = updater(state)
if (Object.is(state, next)) return // 조기 반환!
const prev = state
state = next
onChange?.(prev, next)
listeners.forEach(l => l())
},
subscribe(listener: () => void) {
listeners.add(listener)
return () => listeners.delete(listener)
}
}
}
핵심 설계 결정:
Object.is 동등성 체크: updater가 같은 참조를 반환하면 업데이트, 사이드 이펙트, 리스너 통지 모두 건너뜁니다. 불필요한 재렌더를 원천 차단합니다.setState가 부분 객체가 아닌 (prev) => next 형태의 업데이터 함수를 받습니다. 이전 상태를 기반으로 안전하게 업데이트할 수 있으며, 동시 업데이트 시 경쟁 조건을 방지합니다.AppState는 약 400개 필드를 가진 거대한 타입으로, 6개 논리 범주로 구성됩니다:
// AppState 타입 패턴 (간소화)
type AppState = DeepImmutable<{
// 세션 코어
sessionId: SessionId
model: ModelId
messages: Message[]
totalCostUSD: number
// UI 상태
expandedView: boolean
verbose: boolean
inputMode: InputMode
// 권한
permissionMode: PermissionMode
approvedTools: Set<string>
// ... 약 390개 추가 필드
}> & {
// 가변 필드 — DeepImmutable에서 제외
mutableMessages: MutableMessage[]
}
DeepImmutable<...> & { 가변필드 } 패턴은 대부분의 상태를 불변으로 보호하면서, 성능상 가변성이 필요한 필드(예: 대화 히스토리의 스트리밍 업데이트)를 명시적으로 허용합니다.
onChangeAppState는 createStore의 onChange 콜백으로 등록되어 모든 상태 전환에서 실행되는 단일 관찰자입니다.
처리하는 사이드 이펙트:
이전에는 권한 모드를 변경하는 8개 이상의 코드 경로 중 2개만 CCR에 동기화하는 문제가 있었습니다. 일부 경로에서 권한 모드를 변경하면 UI에는 반영되지만 CCR에는 전파되지 않아, 실제 도구 실행 시 예상과 다른 권한이 적용되는 버그가 발생했습니다.
onChangeAppState를 도입하여 모든 상태 변경이 하나의 관찰자를 통과하도록 만들었습니다. 어떤 경로에서 권한 모드를 변경하든 자동으로 CCR에 동기화됩니다.
React 컴포넌트가 AppState에 접근하는 세 가지 훅:
useAppState(selector): 선택자 함수로 필요한 상태만 구독. Object.is로 비교하여 선택된 값이 변하지 않으면 재렌더하지 않음useSetAppState(): 상태 업데이트 함수 반환. setState와 동일한 updater 패턴useAppStateStore(): 스토어 인스턴스 자체를 반환. 렌더링 외부에서 getState()로 현재 값을 읽어야 할 때 사용AppStateProvider는 이 스토어를 React 트리에 주입하는 얇은 브리지입니다. 동시에 notification, voice, mailbox, speculation 같은 React 전용 provider들은 따로 감싸며, 이런 상태를 전부 AppState로 밀어 넣지 않습니다. 즉 Provider 레이어는 "공용 도메인 상태"와 "UI 전용 보조 상태"를 나누는 경계 역할도 합니다.
선택자가 매 호출마다 새 객체를 반환하면, 관련 없는 업데이트에도 매번 다시 렌더될 수 있습니다:
// 위험! 매번 새 객체 생성 → Object.is 항상 false → 스토어 변경마다 재렌더
useAppState(s => ({ a: s.a, b: s.b }))
// 안전한 대안 1: 개별 값 구독
const a = useAppState(s => s.a)
const b = useAppState(s => s.b)
// 안전한 대안 2: selectors.ts의 순수 selector 재사용
Object.is는 참조 비교이므로, { a: s.a, b: s.b }는 값이 같더라도 매번 새 객체이므로 항상 불일치로 판단됩니다. 그래서 인라인 객체/배열 selector보다는 primitive를 직접 고르거나, selectors.ts에 둔 재사용 가능한 순수 selector를 쓰는 쪽이 맞습니다.
selectors.ts는 Pick<AppState, ...>를 받는 순수 함수 모음입니다. 전체 AppState가 아닌 필요한 필드만 받기 때문에:
여기서 중요한 실무 규칙은, selector가 "새 구조를 조립하는 곳"이 아니라 "이미 있는 상태에서 읽어내는 곳"에 가깝다는 점입니다. 새 객체를 만들어야 한다면 호출부 인라인보다 별도 selector 함수로 빼서 의존 필드를 명확히 드러내는 편이 낫습니다.
teammateViewHelpers.ts는 팀메이트 에이전트의 유지/퇴거 생명주기를 관리하는 전환 헬퍼입니다. 팀메이트의 존재 여부와 상태 전이는 React 화면만의 문제가 아니라 작업 큐, 에이전트 상태, 비React 소비자도 함께 읽어야 하는 도메인 정보이므로, 단순 UI Context가 아니라 상태 전환 헬퍼 쪽에 가깝게 배치됩니다.
상태 관리의 전체 데이터 흐름을 하나의 다이어그램으로 정리합니다. 사용자 인터랙션 또는 API 응답이 스토어를 업데이트하고, 사이드 이펙트를 거쳐 UI에 반영되기까지의 과정입니다.
핵심 포인트: setState는 항상 Object.is로 변경 여부를 판단한 후에만 onChangeAppState와 리스너 통지를 수행합니다. 이 체크가 불필요한 사이드 이펙트와 재렌더를 원천 차단하는 게이트키퍼 역할을 합니다.
useSyncExternalStore가 등록한 구독자. 상태 변경 시 선택자 기반으로 필요한 컴포넌트만 재렌더Claude Code에서 모든 공유 데이터가 AppState에 사는 것은 아닙니다. React Context에 배치되는 데이터와 AppState에 배치되는 데이터의 경계를 이해하는 것이 중요합니다.
아래 데이터는 AppState가 아닌 전용 React Context에 배치됩니다:
modalContext — 모달 다이얼로그의 표시 여부와 콘텐츠. 일시적이고 UI 전용인 상태overlayContext — 오버레이 레이어의 표시/숨김 제어promptOverlayContext — 프롬프트 오버레이(권한 요청 등)의 표시 상태notifications — 토스트 알림 큐. 표시 후 자동 제거되고, 타이머와 dismiss 콜백을 동반하는 UI 전용 상태fpsMetrics — 프레임 레이트 모니터링 데이터. 디버깅 전용mailbox — 컴포넌트 간 비동기 메시지 전달 채널voice — 음성 입력 상태와 설정speculation — 응답 중간 표시나 예측 렌더링에 가까운 임시 UI 상태QueuedMessage — 전송 대기 중인 메시지 큐AppState에 배치되는 데이터와 Context에 배치되는 데이터의 경계는 다음 기준으로 결정됩니다:
AppState. 일시적으로 표시되고 사라지는 데이터 → ContextAppState. 순수 UI 컴포넌트에서만 소비되는 데이터 → ContextAppState (onChangeAppState가 처리). UI 내부에서만 소비되는 타이머, dismiss, overlay 제어 같은 값 → Context정리하면, notification이나 speculation은 "앱 전체가 알아야 하는 도메인 상태"라기보다 특정 Provider가 소유한 UI 협조 상태입니다. 반대로 permissionMode, model, teammate 상태 같은 값은 React 밖에서도 읽히고, 선택자와 전환 헬퍼의 대상이 되므로 AppState에 남습니다.
// AppState — 영속적, React 외부 접근, 사이드 이펙트 필요
type AppState = {
sessionId: SessionId // 세션 코어 — 영속
permissionMode: PermissionMode // 권한 — CCR 동기화 필요
model: ModelId // 설정 — 디스크 저장 필요
messages: Message[] // 대화 히스토리 — 백엔드 소비
}
// React Context — 일시적, UI 전용, 사이드 이펙트 불필요
const ModalContext = createContext<ModalState>(...)
const NotificationContext = createContext<Notification[]>(...)
const VoiceContext = createContext<VoiceState>(...)
일시적 UI 데이터(예: 모달 표시 여부)를 AppState에 넣으면, 모든 모달 열기/닫기가 onChangeAppState를 트리거하여 불필요한 CCR 동기화나 디스크 쓰기를 유발합니다. 반대로 영속이 필요한 데이터를 Context에 넣으면 세션 복원 시 유실됩니다. 경계를 올바르게 판단하는 것이 성능과 정확성 모두에 영향을 미칩니다.
createStore, 35줄) → Domain(AppState + AppStateStore) → React(Provider + hooks)createStore는 Object.is 동등성 체크로 같은 참조 반환 시 업데이트/사이드 이펙트/리스너 통지를 모두 건너뜁니다AppState는 DeepImmutable<...> & { 가변필드 } 패턴으로 불변성과 성능을 균형 잡습니다onChangeAppState는 모든 상태 전환의 단일 관찰자로, 8개 이상의 변경 경로에서 2개만 동기화하던 문제를 해결합니다useAppState(s => ({...}))처럼 새 객체를 반환하는 선택자는 Object.is 참조 비교로 인해 스토어 변경마다 불필요한 재렌더를 유발합니다AppStateStore.ts가 .tsx가 아닌 .ts인 이유는 비React 소비자가 React 의존성 없이 임포트할 수 있도록 하기 위해서입니다Pick<AppState, ...>를 받는 순수 함수로, 테스트와 재사용이 용이합니다Q1. updater가 같은 참조를 반환할 때 setState의 동작은?
Object.is(state, next)가 true이면 즉시 반환합니다. 상태 변경, onChange 콜백, 리스너 통지 어느 것도 실행되지 않아 불필요한 재렌더를 원천 차단합니다.Q2. AppStateStore.ts가 .tsx가 아닌 .ts인 이유는?
.tsx 파일은 JSX를 포함할 수 있어 React 관련 타입 체크가 활성화됩니다. .ts로 유지하면 Node.js 백엔드 로직이나 테스트 등 React 없는 환경에서도 의존성 없이 스토어를 임포트할 수 있습니다.Q3. useAppState(s => ({ a: s.a, b: s.b }))의 위험은?
{ a: s.a, b: s.b }라는 새 객체를 생성하면, 값이 동일하더라도 Object.is는 참조가 다르므로 항상 false를 반환합니다. 결과적으로 어떤 상태 변경이든 이 컴포넌트의 재렌더를 트리거합니다.Q4. onChangeAppState 도입 전 권한 모드 동기화의 문제는?
Q5. 권한 모드가 default에서 bubble로 변경될 때 onChangeAppState의 동작은?
toExternalPermissionMode()는 내부 권한 모드를 외부 API에서 인식 가능한 모드로 매핑합니다. bubble은 default로 매핑되므로, 외부 관점에서는 변경이 없어 CCR과 SDK 모두 통지하지 않습니다.