eunsoolib
React 컴포넌트·훅

query-view

TanStack Query 상태 매트릭스를 뷰 슬롯으로 투영하는 레이어

설치

pnpm add @cbcruk/query-view

react가 peer dependency다. @tanstack/react-query는 import하지 않는다 — 아래 비용 참조.

상태는 합이 아니라 곱이다

Query의 상태는 4상태 sum type이 아니라 두 축의 product다.

fetchStatus: idlefetchingpaused
pending (data 없음)idleloadingloading +paused
successreadyready +refreshingready +paused
errorfailed / degraded+retrying+paused

src/query-state.test.ts가 이 10칸을 전부 검증한다.

교체(replace)와 중첩(overlay)

이 라이브러리의 유일한 주장이다.

첫 data가 도착하기 전에는 상태가 서로를 교체하고, 도착한 후에는 상태가 콘텐츠를 장식한다.

switch는 본질적으로 "N중 택1"이라 후자를 표현할 수 없다. 그래서 QueryStatephase(교체)와 modifier(중첩)로 갈리고, 슬롯도 두 종류가 된다.

슬롯시그니처
교체idleReactNode
교체placeholder(elapsed, state) => ReactNode
교체fallback(error, retry, state) => ReactNode
중첩provisional(content) => ReactNode
중첩refreshing(content) => ReactNode
중첩paused(content) => ReactNode
중첩degraded(error, retry, content) => ReactNode
성공 경로 위emptyReactNode | ((data) => ReactNode)

중첩 슬롯이 (content) => ReactNode 데코레이터인 게 핵심이다. 감싸는 순서는 교체 층의 포함관계를 그대로 따른다.

degraded ⊃ paused ⊃ refreshing ⊃ provisional ⊃ content

fallbackdegraded같은 에러에 대한 다른 슬롯이라는 점이 밀도의 핵심이다. 갈림길 판정은 data !== undefined 하나로 끝난다. 검색 결과가 이미 떠 있는데 새로고침이 실패했다면 흰 화면 + 에러 패널이 아니라 기존 내용 + 배너가 맞고, 그 구분을 타입이 강제한다.

사용법

두 경로

useQueryQueryView

매트릭스 전체가 컴포넌트 안에 있다. 교체 + 중첩 슬롯을 다 받는다.

import { QueryView } from '@cbcruk/query-view'

function PostsPage({ keyword }: { keyword: string }) {
  const query = useQuery({ queryKey: ['posts', keyword], queryFn: fetchPosts })

  return (
    <QueryView
      query={query}
      idle={<p>검색어를 입력하세요</p>}
      placeholder={(elapsed) =>
        elapsed > 5000 ? <p>응답이 늦어지고 있습니다</p> : <PostSkeleton />
      }
      fallback={(error, retry, state) => (
        <ErrorPanel error={error} onRetry={retry} disabled={state.retrying} />
      )}
      refreshing={(content) => <div aria-busy>{content}</div>}
      paused={(content) => (
        <>
          <Banner tone="info">오프라인 — 저장된 내용입니다</Banner>
          {content}
        </>
      )}
      degraded={(error, retry, content) => (
        <>
          <Banner tone="warn" onRetry={retry} />
          {content}
        </>
      )}
      provisional={(content) => <div style={{ opacity: 0.5 }}>{content}</div>}
      empty={<EmptyState keyword={keyword} />}
    >
      {(posts) => <PostList posts={posts} />}
    </QueryView>
  )
}

children이 받는 posts는 항상 확정된 값이라 ?? []가 필요 없다.

useSuspenseQueryAsyncBoundary + SuspenseQueryView

useSuspenseQuerypendingerror도 반환하지 않는다. 둘 다 경계로 올라간다. 그래서:

  • 교체 층이 통째로 AsyncBoundary로 이동
  • SuspenseQueryView에는 중첩 슬롯 + empty만 남음 (Omit으로 타입에서 제거)

경계와 뷰가 경쟁 관계가 아니라 층으로 분리된다.

import { AsyncBoundary, Delayed, SuspenseQueryView } from '@cbcruk/query-view'

function PostsRoute({ keyword }: { keyword: string }) {
  return (
    <AsyncBoundary
      placeholder={
        <Delayed>
          <PostSkeleton />
        </Delayed>
      }
      fallback={(error, retry) => <ErrorPanel error={error} onRetry={retry} />}
      resetKeys={[keyword]}
      onReset={() => queryClient.resetQueries({ queryKey: ['posts'] })}
    >
      <PostsPanel keyword={keyword} />
    </AsyncBoundary>
  )
}

function PostsPanel({ keyword }: { keyword: string }) {
  const query = useSuspenseQuery({
    queryKey: ['posts', keyword],
    queryFn: fetchPosts,
  })

  return (
    <SuspenseQueryView
      query={query}
      refreshing={(content) => <div aria-busy>{content}</div>}
      empty={<EmptyState keyword={keyword} />}
    >
      {(posts) => <PostList posts={posts} />}
    </SuspenseQueryView>
  )
}

SuspenseQueryView는 개발 빌드에서 교체 층 상태가 들어오면 경고한다 — useQuery를 잘못 넘긴 경우를 잡는다.

empty가 경계로 올라가지 못하는 것도 같은 원리다. "결과가 비었다"는 판정은 data를 본 쪽만 할 수 있으니 가장 안쪽에 남는다.

전체 사용례는 src/query-view.example.tsx.

API

컴포넌트

이름역할
QueryViewuseQuery 결과를 교체 + 중첩 슬롯으로 투영
SuspenseQueryViewuseSuspenseQuery 결과를 중첩 슬롯 + empty로 투영
AsyncBoundary교체 층 전담 경계. ErrorBoundary + Suspense
ErrorBoundary동작하는 리셋 경로(retry, resetKeys)를 갖춘 에러 경계
Delayeddelay 이후에만 자식을 렌더. Suspense fallback의 깜빡임 방지
Collectionempty 슬롯만 단독으로

이름역할
useDelayedFlagdelay / minDuration 시간 정책으로 표시 여부를 감쌈
useElapsedtick 간격 경과 시간(ms) 티커

함수 · 타입

이름역할
toQueryStateQueryLikeQueryState 분류. 이 라이브러리에서 Query의 형태를 아는 유일한 함수
hasDataready / degraded로 좁히는 타입 가드
defaultIsEmpty기본 empty 판정 (배열 · 문자열 · Map · Set · nullish)
isAbortError취소 판별. 자동 적용되지 않으니 슬롯 안에서 직접 거른다
assertNeverphase 추가를 컴파일 에러로 잡는 exhaustiveness 체크
QueryState · QueryLike · QueryStateOf타입

시간 정책

placeholder에만 delay(기본 200ms)와 minDuration(기본 400ms)이 붙는다. 빠른 응답에 스켈레톤이 번쩍이는 것과, 스켈레톤이 한 프레임만 스치는 것을 동시에 막는다.

fallback 쪽엔 대응물이 없다. 실패는 즉시 확정된 사실이라 "너무 빨리 실패해서 안 보여준다"가 성립하지 않는다. 지연만이 시간에 대한 정책을 요구한다.

Suspense fallback에는 minDuration을 적용할 수 없다 — 데이터가 도착하면 React가 fallback 트리를 즉시 언마운트하므로 유지할 주체가 사라진다. 그래서 Delayeddelay만 지킨다.

empty 슬롯의 기본값

empty를 넘기지 않으면 결과가 비어도 children이 그대로 렌더된다. 빈 목록에 헤더나 툴바를 함께 그리는 화면이 많아, 슬롯을 생략했을 때 화면이 통째로 비는 쪽이 더 놀랍기 때문이다. 빈 결과를 명시적으로 지우려면 empty={null}을 넘긴다.

판정은 defaultIsEmpty가 하고, isEmpty prop으로 대체할 수 있다. 키 없는 plain object는 일부러 다루지 않는다 — {}가 빈 결과인지 필드가 전부 optional인 유효 응답인지는 도메인만 안다.

Query는 defaultplaceholder를 이미 정확히 구분한다

이 방향을 밀 근거이자, 이 분류가 외부에서 독립적으로 검증된 사례다.

Query 옵션캐시분류
initialData들어감. staleTime 적용받는 진짜 값default
placeholderData안 들어감. isPlaceholderData: true로 표시placeholder

React가 Suspense에서 뭉갠 구분을 Query는 데이터 층에서 올바른 이름으로 갖고 있다. provisional 슬롯이 존재하는 이유가 여기다 — placeholder가 렌더 층이 아니라 데이터 층에 있는 유일한 칸이고, placeholderData: keepPreviousData는 한 술 더 떠 "이전 결과를 placeholder로 재활용"이라는 원래 분류에 없던 셀이다.

의도적으로 뺀 것: stale 오버레이

isStaleQueryState.ready에 노출하지만 슬롯으로는 만들지 않았다. staleTime 기본값이 0이라 거의 항상 true이고, 슬롯으로 열어두면 상시 켜진 배너가 된다. 필요하면 toQueryState를 직접 써서 처리한다.

왜 범용 Resource<T> 어댑터가 아닌가

Resource<T> 4상태 유니온으로 Query를 받는 어댑터는 손실 어댑터다. 표현이 불가능한 것:

  • success + fetching — 데이터가 있으면서 로딩 중. Query에서 가장 흔한 상태
  • error + 이전 data — 위 degraded 전체
  • paused — pending도 error도 아닌 제3의 축
  • isPlaceholderData — 값은 있지만 대역

범용 모델로 투영하는 순간 원본의 절반이 날아간다. 손실 없는 투영을 하려면 대상 모델에 붙어야 하고, 결합이 버그가 아니라 이 설계의 기능이다. 그 대가로 이건 범용 라이브러리가 아니라 Query 코드베이스의 컨벤션에 가깝다.

비용

  • 버전 결합. v4→v5에서 isLoadingisPending, status: 'loading''pending' 개명이 있었고 뷰 레이어가 그대로 먹는다. 현재 React 어댑터는 v5(5.10x 대)이고 v6은 Svelte/Solid 쪽이다.
  • 완충 장치. QueryLike<T>가 구조적 타입이라 @tanstack/react-query를 import하지 않는다. 개명이 오면 types.tstoQueryState 한 함수만 고치면 되고, 테스트에서 plain object로 상태를 주입할 수 있다.

테스트

pnpm test:run packages/query-view
파일검증 대상
src/query-state.test.ts매트릭스 10칸 분류와 modifier
src/query-state.utils.test.tshasData · defaultIsEmpty · isAbortError · assertNever
src/query-view.test.tsx교체/중첩 슬롯, 중첩 순서, empty, 재시도 경로
src/suspense-query-view.test.tsx중첩 층 위임과 개발 빌드 경고
src/use-delayed-flag.test.tsxdelay / minDuration 시간 정책
src/use-elapsed.test.tsxtick 갱신과 정지
src/error-boundary.test.tsxretry · resetKeys 리셋 경로
src/async-boundary.test.tsxSuspense 지연, 실패, 하위 트리 재마운트
src/delayed.test.tsxSuspense fallback 지연
src/collection.test.tsx단독 empty 슬롯

On this page