본문으로 건너뛰기

TanStack DB 라이브 쿼리

TanStack DB는 SQL과 유사한 플루언트 API를 사용하여 컬렉션의 데이터를 가져오고, 필터링하고, 변환하고, 집계할 수 있는 강력한 타입 안전 쿼리 시스템을 제공합니다. 모든 쿼리는 기본적으로 라이브이므로 기본 데이터가 변경되면 자동으로 업데이트됩니다.

쿼리 시스템은 Kysely 또는 Drizzle과 같은 SQL 쿼리 빌더와 유사한 API를 기반으로 하며, 메서드를 연결하여 쿼리를 구성합니다. 쿼리 빌더는 메서드 호출 순서대로 작업을 수행하지 않습니다. 대신 쿼리를 최적의 증분 파이프라인으로 구성하고, 이를 효율적으로 컴파일하고 실행합니다. 각 메서드는 새 쿼리 빌더를 반환하므로 작업을 연결할 수 있습니다.

라이브 쿼리는 기본 데이터가 변경될 때 자동으로 업데이트되는 컬렉션으로 확인됩니다. 변경 사항을 구독하고, 결과를 반복 처리하며, 모든 표준 컬렉션 메서드를 사용할 수 있습니다.

import { createCollection, liveQueryCollectionOptions, eq } from '@tanstack/db'

const activeUsers = createCollection(liveQueryCollectionOptions({
query: (q) =>
q
.from({ user: usersCollection })
.where(({ user }) => eq(user.active, true))
.select(({ user }) => ({
id: user.id,
name: user.name,
email: user.email,
}))
}))

결과 유형은 쿼리 구조에서 자동으로 추론되므로 완전한 TypeScript 지원을 제공합니다. select 절을 사용하면 결과 유형이 프로젝션과 일치합니다. select이 없으면 올바른 조인 선택성과 함께 전체 스키마를 가져옵니다.

가상 속성

라이브 쿼리 결과에는 각 행에 계산된 읽기 전용 가상 속성이 포함됩니다:

  • $synced: 행이 동기화로 확인되면 true이고, 아직 낙관적이면 false입니다.
  • $origin: 마지막으로 확인된 변경이 이 클라이언트에서 발생한 경우 "local"이고, 그렇지 않으면 "remote"입니다.
  • $key: 결과의 행 키입니다.
  • $collectionId: 원본 컬렉션 ID입니다.

이러한 속성은 where, selectorderBy 절에서 사용할 수 있습니다. 이러한 속성은 쿼리 출력에 자동으로 추가되며 스토리지에 다시 영속화해서는 안 됩니다.

목차

라이브 쿼리 컬렉션 생성

라이브 쿼리 컬렉션을 생성하려면 liveQueryCollectionOptions와 함께 createCollection을 사용하거나 편의 함수 createLiveQueryCollection을 사용할 수 있습니다.

liveQueryCollectionOptions 사용

라이브 쿼리를 만드는 기본 방법은 liveQueryCollectionOptions을 사용하고 createCollection을 함께 전달하는 것입니다:

import { createCollection, liveQueryCollectionOptions, eq } from '@tanstack/db'

const activeUsers = createCollection(liveQueryCollectionOptions({
query: (q) =>
q
.from({ user: usersCollection })
.where(({ user }) => eq(user.active, true))
.select(({ user }) => ({
id: user.id,
name: user.name,
}))
}))

구성 옵션

더 세밀하게 제어하려면 추가 옵션을 지정할 수 있습니다:

const activeUsers = createCollection(liveQueryCollectionOptions({
id: 'active-users', // Optional: auto-generated if not provided
query: (q) =>
q
.from({ user: usersCollection })
.where(({ user }) => eq(user.active, true))
.select(({ user }) => ({
id: user.id,
name: user.name,
})),
getKey: (user) => user.id, // Optional: uses stream key if not provided
startSync: true, // Optional: starts sync immediately
}))
옵션타입설명
idstring (선택 사항)라이브 쿼리의 선택적 고유 식별자입니다. 제공하지 않으면 자동으로 생성됩니다. 디버깅 및 로깅에 유용합니다.
queryQueryBuilder 또는 함수쿼리 정의입니다. Query 인스턴스 또는 Query 인스턴스를 반환하는 함수 중 하나입니다.
getKey(item) => string | number (선택 사항)각 행에서 고유 키를 추출하는 함수입니다. 제공하지 않으면 스트림의 내부 키가 사용됩니다. 단순한 경우에는 부모 컬렉션의 키가 사용되지만, 조인의 경우 자동 생성된 키는 부모 키의 복합 키가 됩니다. 결과 컬렉션에 부모 컬렉션의 특정 키를 사용하려면 getKey를 사용하는 것이 유용합니다.
schemaSchema (선택 사항)검증을 위한 선택적 스키마입니다.
startSyncboolean (선택 사항)즉시 동기화를 시작할지 여부입니다. 기본값은 true입니다.
gcTimenumber (선택 사항)가비지 컬렉션 시간(밀리초)입니다. 기본값은 5000(5초)입니다.

편의 함수

더 간단한 경우에는 바로 가기로 createLiveQueryCollection을 사용할 수 있습니다:

import { createLiveQueryCollection, eq } from '@tanstack/db'

const activeUsers = createLiveQueryCollection((q) =>
q
.from({ user: usersCollection })
.where(({ user }) => eq(user.active, true))
.select(({ user }) => ({
id: user.id,
name: user.name,
}))
)

queryOnce를 사용한 일회성 쿼리

일회성 스냅샷(지속적인 반응성 없음)이 필요하면 queryOnce를 사용합니다. 이 함수는 라이브 쿼리 컬렉션을 생성하고, 미리 로드하고, 결과를 추출한 다음 자동으로 정리하므로 cleanup()을 호출할 필요가 없습니다.

import { eq, queryOnce } from '@tanstack/db'

// Basic one-shot query
const activeUsers = await queryOnce((q) =>
q
.from({ user: usersCollection })
.where(({ user }) => eq(user.active, true))
.select(({ user }) => ({ id: user.id, name: user.name }))
)

// Single result with findOne()
const user = await queryOnce((q) =>
q
.from({ user: usersCollection })
.where(({ user }) => eq(user.id, userId))
.findOne()
)

스크립트, 백그라운드 작업, 데이터 내보내기 또는 AI/LLM 컨텍스트 구축에는 queryOnce를 사용합니다. 일치하는 행이 없으면 findOne()undefined로 확인됩니다. UI 바인딩 및 반응형 업데이트에는 대신 라이브 쿼리를 사용합니다.

프레임워크와 함께 사용

React에서는 useLiveQuery 훅을 사용할 수 있습니다:

import { eq, useLiveQuery } from '@tanstack/react-db'

function UserList() {
const { data: activeUsers } = useLiveQuery({
query: (q) =>
q
.from({ user: usersCollection })
.where(({ user }) => eq(user.active, true)),
})

return (
<ul>
{activeUsers.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)
}

Angular에서는 injectLiveQuery 함수를 사용할 수 있습니다:

import { Component } from '@angular/core'
import { injectLiveQuery } from '@tanstack/angular-db'

@Component({
selector: 'user-list',
template: `
@for (user of activeUsers.data(); track user.id) {
<li>{{ user.name }}</li>
}
`
})
export class UserListComponent {
activeUsers = injectLiveQuery((q) =>
q
.from({ user: usersCollection })
.where(({ user }) => eq(user.active, true))
)
}

참고: React 훅은 기본적으로 구조화된 쿼리 IR에서 쿼리 ID를 파생합니다. 하위 호환성을 위해 의존성 배열도 여전히 허용되지만, 개발 환경에서는 경고가 표시되며 1.0에서 제거됩니다. 해시할 수 없는 쿼리도 경고를 표시하고 1.0까지 기존 마운트 안정 ID를 유지합니다. 캡처된 불투명 값을 반응형으로 만들려면 queryKey를 추가합니다. 자세한 내용은 React 어댑터 문서를 참조할 수 있습니다.

서버 렌더링 및 하이드레이션의 경우 라이브 쿼리 미리 로드는 소스 컬렉션을 암묵적으로 직렬화하지 않고 정렬된 쿼리 결과를 전송합니다. 명시적인 컬렉션 미리 로드는 정규화된 컬렉션 행을 계속 전송합니다. SSR 및 하이드레이션 가이드를 참조할 수 있습니다.

React에 쿼리 키가 필요한 경우

queryKey은 쿼리에 .fn.where, .fn.select 또는 .fn.having처럼 구조화된 IR로 나타낼 수 없는 불투명한 런타임 로직이 포함될 때 사용합니다. 이 키가 해당 쿼리의 명시적 식별자가 됩니다:

function UserSearch({ search }: { search: string }) {
const { data } = useLiveQuery({
queryKey: [usersCollection.id, 'search', search],
query: (q) =>
q
.from({ user: usersCollection })
.fn.where(({ user }) =>
user.name.toLowerCase().includes(search.toLowerCase())
),
})

return <div>{data.length} users</div>
}

매우 빈번한 렌더링 경로에서는 성능을 위한 우회 수단으로 queryKey도 제공할 수 있지만, 일반적인 구조화된 쿼리에서는 생략해야 합니다.

React 개발 빌드는 두 경우를 모두 감지합니다. 1.0 이전에는 불투명하고 해시할 수 없는 IR에 대해 경고를 표시하고 기존 마운트 안정 ID를 유지합니다. 반복되는 비용이 큰 ID 파생에 대해서도 한 번 경고합니다. 두 경고 모두 동일한 queryKey 우회 수단을 가리킵니다.

프레임워크 통합에 대한 자세한 내용은 React, VueAngular 어댑터 문서를 참조할 수 있습니다.

React Suspense와 함께 사용

React 애플리케이션에서는 useLiveSuspenseQuery 훅을 사용하여 React Suspense 경계와 통합할 수 있습니다. 이 훅은 처음 데이터를 로드하는 동안 렌더링을 일시 중단한 다음, 다시 일시 중단하지 않고 업데이트를 스트리밍합니다.

import { useLiveSuspenseQuery } from '@tanstack/react-db'
import { Suspense } from 'react'

function UserList() {
// This will suspend until data is ready
const { data } = useLiveSuspenseQuery({
query: (q) =>
q
.from({ user: usersCollection })
.where(({ user }) => eq(user.active, true)),
})

// data is always defined - no need for optional chaining
return (
<ul>
{data.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)
}

function App() {
return (
<Suspense fallback={<div>Loading users...</div>}>
<UserList />
</Suspense>
)
}

타입 안전성

useLiveQuery과의 주요 차이점은 data가 항상 정의된다는 점입니다(절대 undefined가 아님). 훅은 초기 로드 중 일시 중단되므로 컴포넌트가 렌더링될 때 데이터가 반드시 준비되어 있습니다:

function UserStats() {
const { data } = useLiveSuspenseQuery({
query: (q) => q.from({ user: usersCollection }),
})

// TypeScript knows data is Array<User>, not Array<User> | undefined
return <div>Total users: {data.length}</div>
}

오류 처리

로드 오류를 처리하려면 오류 경계와 함께 사용합니다:

import { ErrorBoundary } from 'react-error-boundary'

function App() {
return (
<ErrorBoundary fallback={<div>Failed to load users</div>}>
<Suspense fallback={<div>Loading users...</div>}>
<UserList />
</Suspense>
</ErrorBoundary>
)
}

반응형 업데이트

초기 로드 후에는 다시 일시 중단하지 않고 데이터 업데이트가 스트리밍됩니다:

function UserList() {
const { data } = useLiveSuspenseQuery({
query: (q) => q.from({ user: usersCollection }),
})

// Suspends once during initial load
// After that, data updates automatically when users change
// UI never re-suspends for live updates
return (
<ul>
{data.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)
}

쿼리 ID 변경 시 다시 일시 중단

파생된 쿼리 ID가 변경되면 새 데이터를 로드하기 위해 훅이 다시 일시 중단됩니다:

function FilteredUsers({ minAge }: { minAge: number }) {
const { data } = useLiveSuspenseQuery({
query: (q) =>
q
.from({ user: usersCollection })
.where(({ user }) => gt(user.age, minAge)),
})

return (
<ul>
{data.map(user => (
<li key={user.id}>{user.name} - {user.age}</li>
))}
</ul>
)
}

사용할 훅 선택

  • useLiveSuspenseQuery 사용:

    • 로딩 상태에 React Suspense를 사용하려는 경우
    • <Suspense><ErrorBoundary> 컴포넌트로 로딩/오류 상태를 처리하려는 경우
    • 정의되지 않는 데이터 타입을 보장하려는 경우
    • 쿼리를 항상 실행해야 하는 경우(조건부가 아님)
  • useLiveQuery 사용:

    • 선택적 쿼리 입력에 조건부 렌더링을 선호하는 경우
    • 컴포넌트 내부에서 로딩/오류 상태를 처리하려는 경우
    • Suspense 없이 인라인으로 로딩 상태를 표시하려는 경우
    • statusisLoading 플래그에 액세스해야 하는 경우
    • 로더가 있는 라우터를 사용하는 경우(React Router, TanStack Router 등) - 로더에서 미리 로드하고 컴포넌트에서 useLiveQuery을 사용합니다.
// useLiveQuery - handle states in component
function UserList() {
const { data, status, isLoading } = useLiveQuery({
query: (q) => q.from({ user: usersCollection }),
})

if (isLoading) return <div>Loading...</div>
if (status === 'error') return <div>Error loading users</div>

return <ul>{data?.map(user => <li key={user.id}>{user.name}</li>)}</ul>
}

// useLiveSuspenseQuery - handle states with Suspense/ErrorBoundary
function UserList() {
const { data } = useLiveSuspenseQuery({
query: (q) => q.from({ user: usersCollection }),
})

return <ul>{data.map(user => <li key={user.id}>{user.name}</li>)}</ul>
}

// useLiveQuery with router loader - recommended pattern
// In your route configuration:
const route = {
path: '/users',
loader: async () => {
// Preload the collection in the loader
await usersCollection.preload()
return null
},
component: UserList,
}

// In your component:
function UserList() {
// Collection is already loaded, so data is immediately available
const { data } = useLiveQuery({
query: (q) => q.from({ user: usersCollection }),
})

return <ul>{data?.map(user => <li key={user.id}>{user.name}</li>)}</ul>
}

조건부 쿼리

선택적 입력의 경우 입력이 존재한 후에만 쿼리 컴포넌트를 렌더링하는 것을 권장합니다. 이렇게 하면 필요한 모든 값이 존재하기 전에 라이브 쿼리가 생성되는 것을 방지할 수 있습니다.

import { useLiveQuery } from '@tanstack/react-db'

function TodosPanel({ userId }: { userId?: string }) {
if (!userId) return <div>Please select a user</div>

return <TodoList userId={userId} />
}

function TodoList({ userId }: { userId: string }) {
const { data } = useLiveQuery({
query: (q) =>
q
.from({ todos: todosCollection })
.where(({ todos }) => eq(todos.userId, userId)),
})

return (
<ul>
{data.map(todo => (
<li key={todo.id}>{todo.text}</li>
))}
</ul>
)
}

query 콜백은 쿼리를 비활성화하기 위해 undefined 또는 null를 반환할 수 있습니다. 이 경우에도 파생된 ID를 사용하므로 캡처된 구조화된 값에 의존성 배열이 필요하지 않습니다:

const { data, isEnabled, status } = useLiveQuery({
query: (q) => {
if (!userId) return undefined

return q
.from({ todos: todosCollection })
.where(({ todos }) => eq(todos.userId, userId))
},
})

최상위 콜백 형식도 동일한 동작을 지원합니다. 쿼리가 비활성화되면:

  • status is 'disabled'
  • data, statecollectionundefined입니다.
  • isEnabled is false
  • isReady is true
  • isLoading, isIdle, isErrorisCleanedUp는 모두 false입니다.

대체 입력 형식

쿼리 빌더 반환(표준)

표준 React 패턴은 쿼리 빌더가 포함된 객체입니다. 구조화된 쿼리의 경우 React가 쿼리 IR에서 ID를 파생합니다:

const { data } = useLiveQuery({
query: (q) =>
q.from({ todos: todosCollection })
.where(({ todos }) => eq(todos.completed, false)),
})

미리 생성된 컬렉션 반환

기존 컬렉션을 직접 구독할 수도 있습니다:

const activeUsersCollection = createLiveQueryCollection((q) =>
q.from({ users: usersCollection })
.where(({ users }) => eq(users.active, true))
)

function UserList() {
const { data } = useLiveQuery(activeUsersCollection)

return <ul>{data.map(user => <li key={user.id}>{user.name}</li>)}</ul>
}

LiveQueryCollectionConfig 반환

사용자 지정 ID와 같은 추가 옵션을 지정하기 위해 구성 객체를 반환할 수 있습니다:

const { data } = useLiveQuery({
query: (q) =>
q.from({ items: itemsCollection })
.select(({ items }) => ({ id: items.id })),
id: 'items-view', // Custom ID for debugging
gcTime: 10000 // Custom garbage collection time
})

다음과 같은 경우에 특히 유용합니다:

  • 디버깅 또는 로깅을 위해 안정적인 ID 연결
  • gcTime 또는 getKey와 같은 컬렉션별 옵션 구성
  • 서로 다른 컬렉션 구성 간 조건부 전환

From 절

모든 쿼리의 기반은 소스 컬렉션 또는 서브쿼리를 지정하는 from 메서드입니다. 객체 구문을 사용하여 소스에 별칭을 지정할 수 있습니다.

단일 소스에는 from()을 사용합니다. 조인 없이 여러 독립 소스를 결합하려면 대신 unionAll()를 사용합니다.

메서드 시그니처

from({
[alias]: Collection | Query
}): Query

매개변수:

  • [alias] - Collection 또는 Query 인스턴스입니다.

기본 사용법

컬렉션에서 모든 레코드를 선택하는 기본 쿼리로 시작합니다:

const allUsers = createCollection(liveQueryCollectionOptions({
query: (q) => q.from({ user: usersCollection })
}))

결과에는 전체 스키마가 포함된 모든 사용자가 포함됩니다. 결과를 반복하거나 키로 액세스할 수 있습니다:

// Get all users as an array
const users = allUsers.toArray

// Get a specific user by ID
const user = allUsers.get(1)

// Check if a user exists
const hasUser = allUsers.has(1)

특히 여러 컬렉션을 사용할 때 쿼리를 더 읽기 쉽게 만들려면 별칭을 사용합니다:

const users = createCollection(liveQueryCollectionOptions({
query: (q) => q.from({ u: usersCollection })
}))

// Access fields using the alias
const userNames = createCollection(liveQueryCollectionOptions({
query: (q) =>
q
.from({ u: usersCollection })
.select(({ u }) => ({
name: u.name,
email: u.email,
}))
}))

unionAll

unionAll()을 시작 메서드로 사용하면 조인 없이 독립적인 소스를 결합할 수 있습니다. 이는 from()을 사용할 위치와 같습니다. 두 가지 형식이 있습니다:

unionAll({
[alias]: Collection | Query,
[alias2]: Collection | Query
}): Query

unionAll(branchQuery, branchQuery2, ...branchQueries): Query

소스 수준 unionAll

객체 형식은 컬렉션 또는 서브쿼리 소스를 결합합니다. 개념적으로 이는 UNION ALL처럼 동작합니다. 각 원시 결과 행은 정확히 하나의 소스 별칭에서 오며, 비활성 별칭은 undefined입니다.

import { coalesce, createLiveQueryCollection } from '@tanstack/db'

const timeline = createLiveQueryCollection((q) =>
q
.unionAll({
message: messagesCollection,
toolCall: toolCallsCollection,
})
.orderBy(({ message, toolCall }) =>
coalesce(message.timestamp, toolCall.timestamp),
)
)

select()이 없으면 결과 타입은 배타적 유니온입니다:

type TimelineRow =
| { message: Message; toolCall?: undefined }
| { message?: undefined; toolCall: ToolCall }

각 분기를 소스와 결합하기 전에 자체 필터링 또는 형태 조정이 필요할 때 서브쿼리를 사용합니다.

select()로 분기 값을 프로젝션하면 비활성 분기의 형태를 제어할 수 있습니다. 예를 들어, 분기와 일치하는 항목이 없으면 caseWhen() 프로젝션은 기본값을 제공하지 않는 한 null을 사용합니다.

쿼리 분기 unionAll

빌드된 쿼리를 직접 전달할 수도 있습니다. 이 형식은 각 분기 쿼리의 결과 행을 유니온합니다. 이후 절은 공유된 결과 형태에서 작동하므로, 정렬 시 coalesce()을 사용하지 않고 공유 필드를 직접 참조할 수 있습니다. select()이 없는 분기는 일반 쿼리 결과 형태를 유지합니다. 예를 들어 조인된 분기는 네임스페이스가 지정된 행으로 유니온에 들어갑니다.

const timeline = createLiveQueryCollection((q) => {
const messageRows = q
.from({ message: messagesCollection })
.select(({ message }) => ({
type: `message` as const,
id: message.id,
body: message.text,
timestamp: message.timestamp,
}))

const toolCallRows = q
.from({ toolCall: toolCallsCollection })
.select(({ toolCall }) => ({
type: `toolCall` as const,
id: toolCall.id,
body: toolCall.name,
timestamp: toolCall.timestamp,
}))

return q
.unionAll(messageRows, toolCallRows)
.orderBy(({ timestamp }) => timestamp)
})

Where 절

where 절을 사용하여 조건에 따라 데이터를 필터링합니다. 여러 where 호출을 연결할 수 있으며, 이는 and 논리로 결합됩니다.

where 메서드는 테이블 별칭이 포함된 객체를 받고 부울 표현식을 반환하는 콜백 함수를 인수로 받습니다. eq(), gt() 같은 비교 함수와 and(), or() 같은 논리 연산자를 사용하여 이러한 표현식을 작성합니다. 이 선언적 접근 방식을 사용하면 쿼리 시스템이 필터를 효율적으로 최적화할 수 있습니다. 자세한 내용은 표현식 함수 참조 섹션에 설명되어 있습니다. 이는 Kysely 또는 Drizzle을 사용하여 쿼리를 구성하는 방식과 매우 유사합니다.

where 메서드는 각 행이나 결과에서 실행되는 함수가 아니라 실행할 쿼리를 설명하는 방법이라는 점에 유의해야 합니다. 이 선언적 접근 방식은 거의 모든 사용 사례에 적합하지만, 더 복잡한 조건을 사용해야 한다면 fn.where과 같은 함수형 변형을 사용할 수 있으며, 이는 함수형 변형 섹션에 설명되어 있습니다.

메서드 시그니처

where(
condition: (row: TRow) => Expression<boolean>
): Query

매개변수:

  • condition - 테이블 별칭이 포함된 행 객체를 받고 부울 표현식을 반환하는 콜백 함수

기본 필터링

간단한 조건으로 사용자를 필터링합니다:

import { eq } from '@tanstack/db'

const activeUsers = createCollection(liveQueryCollectionOptions({
query: (q) =>
q
.from({ user: usersCollection })
.where(({ user }) => eq(user.active, true))
}))

여러 조건

AND 논리를 위해 여러 where 호출을 연결합니다:

import { eq, gt } from '@tanstack/db'

const adultActiveUsers = createCollection(liveQueryCollectionOptions({
query: (q) =>
q
.from({ user: usersCollection })
.where(({ user }) => eq(user.active, true))
.where(({ user }) => gt(user.age, 18))
}))

복잡한 조건

논리 연산자를 사용하여 복잡한 조건을 작성합니다:

import { eq, gt, or, and } from '@tanstack/db'

const specialUsers = createLiveQueryCollection((q) =>
q
.from({ user: usersCollection })
.where(({ user }) =>
and(
eq(user.active, true),
or(
gt(user.age, 25),
eq(user.role, 'admin')
)
)
)
)

사용 가능한 연산자

쿼리 시스템은 여러 비교 연산자를 제공합니다:

import { eq, gt, gte, lt, lte, like, ilike, inArray, and, or, not } from '@tanstack/db'

// Equality
eq(user.id, 1)

// Comparisons
gt(user.age, 18) // greater than
gte(user.age, 18) // greater than or equal
lt(user.age, 65) // less than
lte(user.age, 65) // less than or equal

// String matching
like(user.name, 'John%') // case-sensitive pattern matching
ilike(user.name, 'john%') // case-insensitive pattern matching

// Array membership
inArray(user.id, [1, 2, 3])

// Logical operators
and(condition1, condition2)
or(condition1, condition2)
not(condition)

사용 가능한 모든 함수에 대한 전체 참조는 표현식 함수 참조 섹션을 확인할 수 있습니다.

비교 의미 체계

비교는 원시 JavaScript가 아니라 SQL/PostgreSQL 규칙을 따릅니다:

  • null / undefined은 세 값 논리를 사용합니다. null 또는 undefined과 관련된 모든 비교는 UNKNOWN로 평가되므로 행이 일치하지 않습니다. 예를 들어 eq(user.score, null)은 아무것도 일치시키지 않습니다. 누락된 값을 일치시키려면 전용 null 검사(예: isUndefined)를 사용합니다.
  • NaN은 PostgreSQL 부동 소수점 의미 체계를 따릅니다. NaN은 자기 자신과 같은 것으로 처리되며 다른 모든 null이 아닌 값보다 큽니다. 따라서 eq(row.value, NaN)NaN 행과 일치하고, gt(row.value, x)NaN을 포함하며, 이러한 필드로 정렬하면 NaN이 마지막에 배치됩니다. (잘못된 Date 값은 타임스탬프가 NaN이며 같은 방식으로 동작합니다.) 이는 NaN === NaNfalse인 JavaScript와 다르며, PostgreSQL이 부동 소수점 값을 정렬하고 인덱싱하는 방식과 일치합니다.

선택

select를 사용하여 결과에 포함할 필드를 지정하고 데이터를 변환합니다. select이 없으면 전체 스키마를 가져옵니다.

where 절과 마찬가지로 select 메서드는 테이블 별칭이 포함된 객체를 받고 결과에 포함할 필드가 있는 객체를 반환하는 콜백 함수를 인수로 받습니다. 이를 표현식 함수 참조 섹션의 함수와 결합하여 계산된 필드를 만들 수 있습니다. 스프레드 연산자를 사용하여 테이블의 모든 필드를 포함할 수도 있습니다.

메서드 시그니처

select(
projection: (row: TRow) => Record<string, Expression>
): Query

매개변수:

  • projection - 테이블 별칭이 포함된 행 객체를 받고 선택한 필드 객체를 반환하는 콜백 함수

기본 Select

데이터에서 특정 필드를 선택합니다:

const userNames = createLiveQueryCollection((q) =>
q
.from({ user: usersCollection })
.select(({ user }) => ({
id: user.id,
name: user.name,
email: user.email,
}))
)

/*
Result type: { id: number, name: string, email: string }

```ts
for (const row of userNames) {
console.log(row.name)
}
```
*/

필드 이름 변경

결과에서 필드 이름을 변경합니다:

const userProfiles = createLiveQueryCollection((q) =>
q
.from({ user: usersCollection })
.select(({ user }) => ({
userId: user.id,
fullName: user.name,
contactEmail: user.email,
}))
)

계산된 필드

표현식을 사용하여 계산된 필드를 만듭니다:

import { gt, length } from '@tanstack/db'

const userStats = createLiveQueryCollection((q) =>
q
.from({ user: usersCollection })
.select(({ user }) => ({
id: user.id,
name: user.name,
isAdult: gt(user.age, 18),
nameLength: length(user.name),
}))
)

함수 사용 및 모든 필드 포함

내장 함수를 사용하여 데이터를 변환합니다:

import { concat, upper, gt } from '@tanstack/db'

const formattedUsers = createCollection(liveQueryCollectionOptions({
query: (q) =>
q
.from({ user: usersCollection })
.select(({ user }) => ({
...user, // Include all user fields
displayName: upper(concat(user.firstName, ' ', user.lastName)),
isAdult: gt(user.age, 18),
}))
}))

/*
Result type:
{
id: number,
name: string,
email: string,
displayName: string,
isAdult: boolean,
}
*/

사용 가능한 함수의 전체 목록은 표현식 함수 참조 섹션을 확인할 수 있습니다.

조인

join을 사용하여 여러 컬렉션의 데이터를 결합합니다. 조인은 기본적으로 left 조인 유형을 사용하며 동등 조건만 지원합니다.

TanStack DB의 조인은 여러 컬렉션의 데이터를 결합하는 방법이며, 개념적으로 SQL 조인과 매우 유사합니다. 두 컬렉션을 조인하면 결합된 데이터를 단일 행으로 포함하는 새 컬렉션이 생성됩니다. 새 컬렉션은 라이브 쿼리 컬렉션이며 기본 데이터가 변경될 때 자동으로 업데이트됩니다.

joinselect이 없으면 조인된 컬렉션의 별칭으로 네임스페이스가 지정된 행 객체를 반환합니다.

조인의 결과 타입은 조인 유형을 반영하며, 조인된 필드의 선택 사항 여부는 조인 유형에 따라 결정됩니다.

[!TIP] 평면화된 조인 행 대신 계층적 결과(예: 각 프로젝트와 중첩된 이슈)가 필요하면 아래의 Includes를 확인할 수 있습니다.

메서드 시그니처

join(
{ [alias]: Collection | Query },
condition: (row: TRow) => Expression<boolean>, // Must be an `eq` condition
joinType?: 'left' | 'right' | 'inner' | 'full'
): Query

매개변수:

  • aliases - 키가 별칭 이름이고 값이 조인할 컬렉션 또는 서브쿼리인 객체
  • condition - 결합된 행 객체를 받고 동등 조건을 반환하는 콜백 함수
  • joinType - 선택적 조인 유형: 'left' (기본값), 'right', 'inner' 또는 'full'

기본 Joins

사용자를 게시물과 조인합니다:

const userPosts = createLiveQueryCollection((q) =>
q
.from({ user: usersCollection })
.join({ post: postsCollection }, ({ user, post }) =>
eq(user.id, post.userId)
)
)

/*
Result type:
{
user: User,
post?: Post, // post is optional because it is a left join
}

```ts
for (const row of userPosts) {
console.log(row.user.name, row.post?.title)
}
```
*/

조인 유형

세 번째 매개변수로 조인 유형을 지정합니다:

const activeUserPosts = createLiveQueryCollection((q) =>
q
.from({ user: usersCollection })
.join(
{ post: postsCollection },
({ user, post }) => eq(user.id, post.userId),
'inner', // `inner`, `left`, `right` or `full`
)
)

또는 별칭 leftJoin, rightJoin, innerJoinfullJoin 메서드를 사용합니다:

왼쪽 조인

// Left join - all users, even without posts
const allUsers = createLiveQueryCollection((q) =>
q
.from({ user: usersCollection })
.leftJoin(
{ post: postsCollection },
({ user, post }) => eq(user.id, post.userId),
)
)

/*
Result type:
{
user: User,
post?: Post, // post is optional because it is a left join
}
*/

오른쪽 조인

// Right join - all posts, even without users
const allPosts = createLiveQueryCollection((q) =>
q
.from({ user: usersCollection })
.rightJoin(
{ post: postsCollection },
({ user, post }) => eq(user.id, post.userId),
)
)

/*
Result type:
{
user?: User, // user is optional because it is a right join
post: Post,
}
*/

내부 조인

// Inner join - only matching records
const activeUserPosts = createLiveQueryCollection((q) =>
q
.from({ user: usersCollection })
.innerJoin(
{ post: postsCollection },
({ user, post }) => eq(user.id, post.userId),
)
)

/*
Result type:
{
user: User,
post: Post,
}
*/

전체 조인

// Full join - all users and all posts
const allUsersAndPosts = createLiveQueryCollection((q) =>
q
.from({ user: usersCollection })
.fullJoin(
{ post: postsCollection },
({ user, post }) => eq(user.id, post.userId),
)
)

/*
Result type:
{
user?: User, // user is optional because it is a full join
post?: Post, // post is optional because it is a full join
}
*/

여러 Joins

단일 쿼리에서 여러 조인을 연결합니다:

const userPostComments = createLiveQueryCollection((q) =>
q
.from({ user: usersCollection })
.join({ post: postsCollection }, ({ user, post }) =>
eq(user.id, post.userId)
)
.join({ comment: commentsCollection }, ({ post, comment }) =>
eq(post.id, comment.postId)
)
.select(({ user, post, comment }) => ({
userName: user.name,
postTitle: post.title,
commentText: comment.text,
}))
)

서브쿼리

서브쿼리를 사용하면 한 쿼리의 결과를 다른 쿼리의 입력으로 사용할 수 있습니다. 서브쿼리는 쿼리 자체에 포함되며 단일 쿼리 파이프라인으로 컴파일됩니다. 이는 단일 작업의 일부로 실행되는 SQL 서브쿼리와 매우 유사합니다.

서브쿼리는 새 쿼리의 from 또는 join 절에서 라이브 쿼리 결과를 사용하는 것과 같지 않다는 점에 유의해야 합니다. 그렇게 하면 중간 결과가 완전히 계산되어 액세스할 수 있지만, 서브쿼리는 부모 쿼리 내부에 존재하고 자체적으로 컬렉션으로 구체화되지 않으므로 더 효율적입니다.

중간 결과 캐싱 섹션에서 라이브 쿼리 결과를 새 쿼리의 from 또는 join 절에 사용하는 방법을 자세히 알아볼 수 있습니다.

from 절의 서브쿼리

서브쿼리를 기본 소스로 사용합니다:

const activeUserPosts = createCollection(liveQueryCollectionOptions({
query: (q) => {
// Build the subquery first
const activeUsers = q
.from({ user: usersCollection })
.where(({ user }) => eq(user.active, true))

// Use the subquery in the main query
return q
.from({ activeUser: activeUsers })
.join({ post: postsCollection }, ({ activeUser, post }) =>
eq(activeUser.id, post.userId)
)
}
}))

join 절의 서브쿼리

서브쿼리 결과와 조인합니다:

const userRecentPosts = createCollection(liveQueryCollectionOptions({
query: (q) => {
// Build the subquery first
const recentPosts = q
.from({ post: postsCollection })
.where(({ post }) => gt(post.createdAt, '2024-01-01'))
.orderBy(({ post }) => post.createdAt, 'desc')
.limit(1)

// Use the subquery in the main query
return q
.from({ user: usersCollection })
.join({ recentPost: recentPosts }, ({ user, recentPost }) =>
eq(user.id, recentPost.userId)
)
}
}))

서브쿼리 중복 제거

동일한 서브쿼리가 쿼리 내에서 여러 번 사용되면 자동으로 중복 제거되어 한 번만 실행됩니다:

const complexQuery = createCollection(liveQueryCollectionOptions({
query: (q) => {
// Build the subquery once
const activeUsers = q
.from({ user: usersCollection })
.where(({ user }) => eq(user.active, true))

// Use the same subquery multiple times
return q
.from({ activeUser: activeUsers })
.join({ post: postsCollection }, ({ activeUser, post }) =>
eq(activeUser.id, post.userId)
)
.join({ comment: commentsCollection }, ({ activeUser, comment }) =>
eq(activeUser.id, comment.userId)
)
}
}))

이 예제에서는 activeUsers 서브쿼리가 두 번 사용되지만 한 번만 실행되어 성능이 향상됩니다.

복잡한 중첩 서브쿼리

여러 수준의 중첩으로 복잡한 쿼리를 작성합니다:

import { count } from '@tanstack/db'

const topUsers = createCollection(liveQueryCollectionOptions({
query: (q) => {
// Build the post count subquery
const postCounts = q
.from({ post: postsCollection })
.groupBy(({ post }) => post.userId)
.select(({ post }) => ({
userId: post.userId,
count: count(post.id),
}))

// Build the user stats subquery
const userStats = q
.from({ user: usersCollection })
.join({ postCount: postCounts }, ({ user, postCount }) =>
eq(user.id, postCount.userId)
)
.select(({ user, postCount }) => ({
id: user.id,
name: user.name,
postCount: postCount.count,
}))
.orderBy(({ userStats }) => userStats.postCount, 'desc')
.limit(10)

// Use the user stats subquery in the main query
return q.from({ userStats })
}
}))

포함

Includes를 사용하면 .select() 내부에 서브쿼리를 중첩하여 계층적 결과를 생성할 수 있습니다. 1:N 관계를 반복 행으로 평면화하는 조인과 달리 각 부모 행에는 관련 항목의 중첩 컬렉션이 포함됩니다.

import { createLiveQueryCollection, eq } from '@tanstack/db'

const projectsWithIssues = createLiveQueryCollection((q) =>
q.from({ p: projectsCollection }).select(({ p }) => ({
id: p.id,
name: p.name,
issues: q
.from({ i: issuesCollection })
.where(({ i }) => eq(i.projectId, p.id))
.select(({ i }) => ({
id: i.id,
title: i.title,
})),
})),
)

각 프로젝트의 issues 필드는 기본 데이터가 변경될 때 증분 방식으로 업데이트되는 라이브 Collection입니다.

상관 조건

자식 쿼리의 .where()에는 자식 필드를 부모 필드에 연결하는 eq()이 포함되어야 합니다. 이것이 상관 조건입니다. 이 조건은 시스템에 자식과 부모의 관계를 알려줍니다.

// The correlation condition: links issues to their parent project
.where(({ i }) => eq(i.projectId, p.id))

상관 조건은 독립적인 .where()으로 나타나거나 and() 내부에 나타날 수 있습니다:

// Also valid — correlation is extracted from inside and()
.where(({ i }) => and(eq(i.projectId, p.id), eq(i.status, 'open')))

상관 필드는 부모의 .select()에 포함될 필요가 없습니다.

추가 필터

하위 쿼리는 상관 조건 외에도 부모 필드를 참조하는 필터를 포함하여 추가 .where() 절을 지원합니다:

q.from({ p: projectsCollection }).select(({ p }) => ({
id: p.id,
name: p.name,
issues: q
.from({ i: issuesCollection })
.where(({ i }) => eq(i.projectId, p.id)) // correlation
.where(({ i }) => eq(i.createdBy, p.createdBy)) // parent-referencing filter
.where(({ i }) => eq(i.status, 'open')) // pure child filter
.select(({ i }) => ({
id: i.id,
title: i.title,
})),
}))

부모를 참조하는 필터는 완전히 반응형입니다. 부모의 필드가 변경되면 하위 결과가 자동으로 업데이트됩니다.

정렬 및 제한

하위 쿼리는 부모별로 적용되는 .orderBy().limit()을 지원합니다:

q.from({ p: projectsCollection }).select(({ p }) => ({
id: p.id,
name: p.name,
issues: q
.from({ i: issuesCollection })
.where(({ i }) => eq(i.projectId, p.id))
.orderBy(({ i }) => i.createdAt, 'desc')
.limit(5)
.select(({ i }) => ({
id: i.id,
title: i.title,
})),
}))

각 프로젝트는 고유한 상위 5개 이슈를 가지며, 모든 프로젝트가 공유하는 이슈 5개가 아닙니다.

toArray

기본적으로 각 하위 결과는 라이브 Collection입니다. 대신 일반 배열을 원하면 하위 쿼리를 toArray()으로 감쌉니다:

import { createLiveQueryCollection, eq, toArray } from '@tanstack/db'

const projectsWithIssues = createLiveQueryCollection((q) =>
q.from({ p: projectsCollection }).select(({ p }) => ({
id: p.id,
name: p.name,
issues: toArray(
q
.from({ i: issuesCollection })
.where(({ i }) => eq(i.projectId, p.id))
.select(({ i }) => ({
id: i.id,
title: i.title,
})),
),
})),
)

toArray()을 사용하면 이슈가 변경될 때마다 프로젝트 행이 다시 방출됩니다. 이를 사용하지 않으면 하위 Collection이 독립적으로 업데이트됩니다.

materialize

materialize()은 여러 행 포함과 단일 행 포함을 모두 처리하는 단일 헬퍼입니다:

  • 래핑된 하위 쿼리가 여러 행을 반환하면 부모는 Array<T>을 받으며, 이는 toArray()과 동일한 형태입니다.
  • 래핑된 하위 쿼리가 .findOne()로 끝나면 부모는 T | undefined을 받으며, 단일 객체이거나 일치하는 하위 항목이 없을 때는 undefined입니다.

하위 쿼리가 최대 한 행만 반환한다는 것을 알고 있는 호출자는 단일 요소 배열을 매번 풀지 않아도 됩니다. 반응형 의미 체계는 toArray()와 일치합니다. 기본 하위 항목이 변경될 때마다 부모 행이 다시 방출되며, 삽입 / 업데이트 / 삭제 전환과 일치 여부가 변경되는 행도 포함됩니다.

import { createLiveQueryCollection, eq, materialize } from '@tanstack/db'

// Multi-row → issues: Array<Issue>
const projectsWithIssues = createLiveQueryCollection((q) =>
q.from({ p: projectsCollection }).select(({ p }) => ({
...p,
issues: materialize(
q
.from({ i: issuesCollection })
.where(({ i }) => eq(i.projectId, p.id)),
),
})),
)

// Singleton → project: Project | undefined
const issuesWithProject = createLiveQueryCollection((q) =>
q.from({ i: issuesCollection }).select(({ i }) => ({
...i,
project: materialize(
q
.from({ p: projectsCollection })
.where(({ p }) => eq(p.id, i.projectId))
.findOne(),
),
})),
)

단일 객체와 배열 중 결과 유형은 래핑된 쿼리가 .findOne()로 끝나는지에 따라 추론되므로 추가 형식 주석이 필요하지 않습니다.

toArray()와 마찬가지로 materialize().select()에서 최상위 값으로만 유효하며, coalesce() 또는 eq()과 같은 표현식 헬퍼 안에 중첩할 수 없습니다.

하위 쿼리, toArray(), materialize() 또는 eq()caseWhen() 같은 쿼리 표현식을 .fn.select()에서 반환하면 안 됩니다. 함수형 select 콜백은 컴파일러가 쿼리 그래프를 만든 뒤 실행되므로 쿼리 연산을 추가할 수 없습니다.

집계

하위 쿼리에서 집계 함수를 사용할 수 있습니다. 집계는 부모별로 계산됩니다:

import { createLiveQueryCollection, eq, count } from '@tanstack/db'

const projectsWithCounts = createLiveQueryCollection((q) =>
q.from({ p: projectsCollection }).select(({ p }) => ({
id: p.id,
name: p.name,
issueCount: q
.from({ i: issuesCollection })
.where(({ i }) => eq(i.projectId, p.id))
.select(({ i }) => ({ total: count(i.id) })),
})),
)

각 프로젝트는 고유한 개수를 가집니다. 이슈가 추가되거나 제거되면 개수가 반응형으로 업데이트됩니다.

중첩 포함

포함은 임의의 깊이로 중첩할 수 있습니다. 예를 들어 프로젝트에는 이슈를 포함할 수 있고, 이슈에는 댓글을 포함할 수 있습니다:

const tree = createLiveQueryCollection((q) =>
q.from({ p: projectsCollection }).select(({ p }) => ({
id: p.id,
name: p.name,
issues: q
.from({ i: issuesCollection })
.where(({ i }) => eq(i.projectId, p.id))
.select(({ i }) => ({
id: i.id,
title: i.title,
comments: q
.from({ c: commentsCollection })
.where(({ c }) => eq(c.issueId, i.id))
.select(({ c }) => ({
id: c.id,
body: c.body,
})),
})),
})),
)

각 수준은 독립적이고 점진적으로 업데이트됩니다. 이슈에 댓글을 추가해도 다른 이슈나 프로젝트를 다시 처리하지 않습니다.

React에서 포함 사용

React에서 포함을 사용할 때는 반응형 업데이트를 받으려면 각 하위 Collection에 자체 useLiveQuery 구독이 필요합니다. 하위 컬렉션을 useLiveQuery(childCollection)을 호출하는 하위 컴포넌트에 전달합니다:

import { useLiveQuery } from '@tanstack/react-db'
import { eq } from '@tanstack/db'

function ProjectList() {
const { data: projects } = useLiveQuery({
query: (q) =>
q.from({ p: projectsCollection }).select(({ p }) => ({
id: p.id,
name: p.name,
issues: q
.from({ i: issuesCollection })
.where(({ i }) => eq(i.projectId, p.id))
.select(({ i }) => ({
id: i.id,
title: i.title,
})),
})),
})

return (
<ul>
{projects.map((project) => (
<li key={project.id}>
{project.name}
{/* Pass the child collection to a subcomponent */}
<IssueList issuesCollection={project.issues} />
</li>
))}
</ul>
)
}

function IssueList({ issuesCollection }) {
// Subscribe to the child collection for reactive updates
const { data: issues } = useLiveQuery(issuesCollection)

return (
<ul>
{issues.map((issue) => (
<li key={issue.id}>{issue.title}</li>
))}
</ul>
)
}

IssueList 컴포넌트는 프로젝트의 이슈를 독립적으로 구독합니다. 이슈가 추가되거나 제거되면 영향을 받은 IssueList만 다시 렌더링되고, 부모 ProjectList는 다시 렌더링되지 않습니다.

[!NOTE] 하위 컬렉션을 하위 컴포넌트에 전달하고 useLiveQuery으로 구독해야 합니다. 구독하지 않고 부모에서 project.issues을 직접 읽으면 컬렉션 객체는 가져오지만 하위 데이터가 변경될 때 컴포넌트가 다시 렌더링되지 않습니다.

groupBy 및 집계

groupBy를 사용하여 데이터를 그룹화하고 집계 함수를 적용합니다. select에서 groupBy 없이 집계를 사용하면 전체 결과 집합이 단일 그룹으로 처리됩니다.

메서드 시그니처

groupBy(
grouper: (row: TRow) => Expression | Expression[]
): Query

매개변수:

  • grouper - 행 객체를 받아 그룹화 키를 반환하는 콜백 함수입니다. 단일 값을 반환하거나 여러 열 그룹화를 위해 배열을 반환할 수 있습니다.

기본 그룹화

사용자를 부서별로 그룹화하고 수를 계산합니다:

import { count, avg } from '@tanstack/db'

const departmentStats = createCollection(liveQueryCollectionOptions({
query: (q) =>
q
.from({ user: usersCollection })
.groupBy(({ user }) => user.departmentId)
.select(({ user }) => ({
departmentId: user.departmentId,
userCount: count(user.id),
avgAge: avg(user.age),
}))
}))

[!NOTE] groupBy 쿼리에서 select 절의 속성은 다음 중 하나여야 합니다:

  • count, sum, avg과 같은 집계 함수
  • groupBy 절에서 사용된 속성

집계되거나 그룹화되지 않은 속성은 선택할 수 없습니다.

[!WARNING] fn.select()groupBy()과 함께 사용할 수 없습니다. groupBy 연산자는 select 절을 정적으로 분석하여 각 그룹에 대해 계산할 집계 함수(count, sum, max 등)를 찾아야 합니다. fn.select()은 불투명한 JavaScript 함수이므로 컴파일러가 검사할 수 없습니다. 표준 .select() API를 groupBy()과 함께 사용합니다.

여러 열 그룹화

콜백에서 배열을 반환하여 여러 열을 기준으로 그룹화합니다:

const userStats = createCollection(liveQueryCollectionOptions({
query: (q) =>
q
.from({ user: usersCollection })
.groupBy(({ user }) => [user.departmentId, user.role])
.select(({ user }) => ({
departmentId: user.departmentId,
role: user.role,
count: count(user.id),
avgSalary: avg(user.salary),
}))
}))

집계 함수

다양한 집계 함수를 사용하여 데이터를 요약합니다:

import { count, sum, avg, min, max } from '@tanstack/db'

const orderStats = createCollection(liveQueryCollectionOptions({
query: (q) =>
q
.from({ order: ordersCollection })
.groupBy(({ order }) => order.customerId)
.select(({ order }) => ({
customerId: order.customerId,
totalOrders: count(order.id),
totalAmount: sum(order.amount),
avgOrderValue: avg(order.amount),
minOrder: min(order.amount),
maxOrder: max(order.amount),
}))
}))

사용 가능한 집계 함수의 전체 목록은 집계 함수 섹션을 참조할 수 있습니다.

Having 절

having를 사용하여 집계된 결과를 필터링합니다. 이는 where 절과 유사하지만 집계가 수행된 후 적용됩니다.

메서드 시그니처

having(
condition: (row: TRow) => Expression<boolean>
): Query

매개변수:

  • condition - 테이블 참조와 $selected을 받아 불리언 표현식을 반환하는 콜백 함수입니다. 쿼리에 select() 절이 있을 때 두 번째 값을 받습니다.
// Using aggregate functions directly
const highValueCustomers = createLiveQueryCollection((q) =>
q
.from({ order: ordersCollection })
.groupBy(({ order }) => order.customerId)
.having(({ order }) => gt(sum(order.amount), 1000))
)

// Using SELECT fields via $selected (recommended when select() is used)
const highValueCustomersWithSelect = createLiveQueryCollection((q) =>
q
.from({ order: ordersCollection })
.groupBy(({ order }) => order.customerId)
.select(({ order }) => ({
customerId: order.customerId,
totalSpent: sum(order.amount),
orderCount: count(order.id),
}))
.having(({ $selected }) => gt($selected.totalSpent, 1000))
)

암시적 단일 그룹 집계

groupBy 없이 집계를 사용하면 전체 결과 집합이 그룹화됩니다:

const overallStats = createLiveQueryCollection((q) =>
q
.from({ user: usersCollection })
.select(({ user }) => ({
totalUsers: count(user.id),
avgAge: avg(user.age),
maxSalary: max(user.salary),
}))
)

이는 전체 컬렉션을 하나의 그룹으로 그룹화하는 것과 같습니다.

그룹화된 데이터 액세스

그룹화된 결과는 그룹 키로 액세스할 수 있습니다:

const deptStats = createCollection(liveQueryCollectionOptions({
query: (q) =>
q
.from({ user: usersCollection })
.groupBy(({ user }) => user.departmentId)
.select(({ user }) => ({
departmentId: user.departmentId,
count: count(user.id),
}))
}))

// Access by department ID
const engineeringStats = deptStats.get(1)

참고: 그룹화 방식에 따라 그룹화된 결과의 키가 다릅니다.

  • 단일 열 그룹화: 실제 값으로 키가 지정됩니다(예: deptStats.get(1)).
  • 여러 열 그룹화: 그룹화된 값의 JSON 문자열로 키가 지정됩니다(예: userStats.get('[1,"admin"]')).

findOne

findOne을 사용하면 배열 대신 단일 결과를 반환합니다. 고유 식별자로 쿼리하는 경우처럼 일치하는 레코드가 최대 하나일 것으로 예상할 때 유용합니다.

findOne 메서드는 반환 유형을 배열에서 단일 객체 또는 undefined로 변경합니다. 일치하는 레코드를 찾지 못하면 결과는 undefined입니다.

메서드 시그니처

findOne(): Query

기본 사용법

ID로 특정 사용자를 찾습니다:

const user = createLiveQueryCollection((q) =>
q
.from({ users: usersCollection })
.where(({ users }) => eq(users.id, 1))
.findOne()
)

// Result type: User | undefined
// If user with id=1 exists: { id: 1, name: 'John', ... }
// If not found: undefined

React Hooks 사용

findOneuseLiveQuery과 함께 사용하여 단일 레코드를 가져옵니다:

import { useLiveQuery } from '@tanstack/react-db'
import { eq } from '@tanstack/db'

function UserProfile({ userId }: { userId: string }) {
const { data: user, isLoading } = useLiveQuery({
query: (q) =>
q
.from({ users: usersCollection })
.where(({ users }) => eq(users.id, userId))
.findOne(),
})

if (isLoading) return <div>Loading...</div>
if (!user) return <div>User not found</div>

return <div>{user.name}</div>
}

Select 사용

findOneselect과 결합하여 특정 필드를 프로젝션합니다:

const userEmail = createLiveQueryCollection((q) =>
q
.from({ users: usersCollection })
.where(({ users }) => eq(users.id, 1))
.select(({ users }) => ({
id: users.id,
email: users.email,
}))
.findOne()
)

// Result type: { id: number, email: string } | undefined

반환 유형 동작

반환 유형은 findOne의 사용 여부에 따라 변경됩니다:

// Without findOne - returns array
const users = createLiveQueryCollection((q) =>
q.from({ users: usersCollection })
)
// Type: Array<User>

// With findOne - returns single object or undefined
const user = createLiveQueryCollection((q) =>
q.from({ users: usersCollection }).findOne()
)
// Type: User | undefined

모범 사례

다음과 같은 경우 사용합니다:

  • 고유 식별자(ID, 이메일 등)로 쿼리할 때
  • 결과가 최대 하나일 것으로 예상할 때
  • 배열 인덱싱 없이 형식이 안전한 단일 레코드 액세스를 원할 때

다음과 같은 경우 피합니다:

  • 일치하는 레코드가 여러 개일 수 있을 때(대신 일반 쿼리를 사용합니다)
  • 결과를 반복해야 할 때

고유 값

distinct를 사용하여 선택한 열을 기준으로 쿼리 결과에서 중복 행을 제거합니다. distinct 연산자는 선택한 값의 각 고유 조합이 결과 집합에 한 번만 나타나도록 합니다.

[!IMPORTANT] distinct 연산자에는 select 절이 필요합니다. 선택할 열을 지정하지 않고 distinct을 사용할 수 없습니다.

메서드 시그니처

distinct(): Query

기본 사용법

단일 열에서 고유한 값을 가져옵니다:

const uniqueCountries = createLiveQueryCollection((q) =>
q
.from({ user: usersCollection })
.select(({ user }) => ({ country: user.country }))
.distinct()
)

// Result contains only unique countries
// If you have users from USA, Canada, and UK, the result will have 3 items

여러 열 Distinct

여러 열의 고유한 조합을 가져옵니다:

const uniqueRoleSalaryPairs = createLiveQueryCollection((q) =>
q
.from({ user: usersCollection })
.select(({ user }) => ({
role: user.role,
salary: user.salary,
}))
.distinct()
)

// Result contains only unique role-salary combinations
// e.g., Developer-75000, Developer-80000, Manager-90000

예외 사례

Null 값

Null 값은 고유한 값으로 처리됩니다:

const uniqueValues = createLiveQueryCollection((q) =>
q
.from({ user: usersCollection })
.select(({ user }) => ({ department: user.department }))
.distinct()
)

// If some users have null departments, null will appear as a distinct value
// Result might be: ['Engineering', 'Marketing', null]

정렬, 제한 및 오프셋

orderBy, limitoffset을 사용하여 결과의 순서와 페이지 매김을 제어합니다. 최적의 성능을 위해 정렬은 점진적으로 수행됩니다.

메서드 시그니처

orderBy(
selector: (row: TRow) => Expression,
direction?: 'asc' | 'desc'
): Query

limit(count: number): Query

offset(count: number): Query

매개변수:

  • selector - 행 객체를 받아 정렬 기준 값을 반환하는 콜백 함수입니다.
  • direction - 정렬 방향: 'asc'(기본값) 또는 'desc'
  • count - 제한하거나 건너뛸 행의 수입니다.

기본 정렬

단일 열을 기준으로 결과를 정렬합니다:

const sortedUsers = createLiveQueryCollection((q) =>
q
.from({ user: usersCollection })
.orderBy(({ user }) => user.name)
.select(({ user }) => ({
id: user.id,
name: user.name,
}))
)

여러 열 정렬

여러 열을 기준으로 정렬합니다:

const sortedUsers = createLiveQueryCollection((q) =>
q
.from({ user: usersCollection })
.orderBy(({ user }) => user.departmentId, 'asc')
.orderBy(({ user }) => user.name, 'asc')
.select(({ user }) => ({
id: user.id,
name: user.name,
departmentId: user.departmentId,
}))
)

unionAll 정렬

소스 수준의 unionAll 쿼리를 정렬할 때는 다음과 같은 결합 표현식을 사용합니다. coalesce()를 사용하면 분기 전체에서 비교 가능한 값 하나를 생성합니다. 쿼리 분기 unionAll()는 대신 다음에 표시된 것처럼 공유된 선택 필드를 기준으로 정렬할 수 있습니다. unionAll() 예제입니다.

순서 표현식이 문자열을 정렬하고 분기 컬렉션의 기본 문자열 데이터 정렬 설정이 서로 다른 경우, TanStack DB는 첫 번째 소스 컬렉션의 데이터 정렬을 기본값으로 사용합니다. 결합된 정렬에 특정 문자열 데이터 정렬이 필요하면 명시적인 orderBy 비교 옵션을 전달합니다:

const timeline = createLiveQueryCollection((q) =>
q
.unionAll({
message: messagesCollection,
toolCall: toolCallsCollection,
})
.select(({ message, toolCall }) => ({
label: coalesce(message.title, toolCall.name),
}))
.orderBy(({ $selected }) => $selected.label, {
stringSort: `locale`,
locale: `en-US`,
})
)

SELECT 필드를 기준으로 정렬

집계 또는 계산된 값과 함께 select()을 사용하면 $selected 네임스페이스를 사용하여 해당 필드를 기준으로 정렬할 수 있습니다:

const topCustomers = createLiveQueryCollection((q) =>
q
.from({ order: ordersCollection })
.groupBy(({ order }) => order.customerId)
.select(({ order }) => ({
customerId: order.customerId,
totalSpent: sum(order.amount),
orderCount: count(order.id),
latestOrder: max(order.createdAt),
}))
.orderBy(({ $selected }) => $selected.totalSpent, 'desc')
.limit(10)
)

내림차순 정렬

내림차순 정렬에는 desc을 사용합니다:

const recentPosts = createLiveQueryCollection((q) =>
q
.from({ post: postsCollection })
.orderBy(({ post }) => post.createdAt, 'desc')
.select(({ post }) => ({
id: post.id,
title: post.title,
createdAt: post.createdAt,
}))
)

limitoffset를 사용한 페이지 매김

offset을 사용하여 결과를 건너뜁니다:

const page2Users = createLiveQueryCollection((q) =>
q
.from({ user: usersCollection })
.orderBy(({ user }) => user.name, 'asc')
.limit(20)
.offset(20) // Skip first 20 results
.select(({ user }) => ({
id: user.id,
name: user.name,
}))
)

조합 가능한 쿼리

작고 재사용 가능한 부분을 조합하여 복잡한 쿼리를 작성합니다. 이 접근 방식은 쿼리의 유지 관리성을 높이고 캐싱을 통해 성능을 향상합니다.

조건부 쿼리 작성

런타임 조건에 따라 쿼리를 작성합니다:

import { Query, eq } from '@tanstack/db'

function buildUserQuery(options: { activeOnly?: boolean; limit?: number }) {
let query = new Query().from({ user: usersCollection })

if (options.activeOnly) {
query = query.where(({ user }) => eq(user.active, true))
}

if (options.limit) {
query = query.limit(options.limit)
}

return query.select(({ user }) => ({
id: user.id,
name: user.name,
}))
}

const activeUsers = createLiveQueryCollection(buildUserQuery({ activeOnly: true, limit: 10 }))

중간 결과 캐싱

라이브 쿼리 컬렉션의 결과 자체가 컬렉션이며, 기본 데이터가 변경되면 자동으로 업데이트됩니다. 따라서 라이브 쿼리 컬렉션의 결과를 다른 라이브 쿼리 컬렉션의 소스로 사용할 수 있습니다. 이 패턴은 중간 결과를 캐시하여 후속 쿼리를 더 빠르게 만들고자 하는 복잡한 쿼리를 작성할 때 유용합니다.

// Base query for active users
const activeUsers = createLiveQueryCollection((q) =>
q
.from({ user: usersCollection })
.where(({ user }) => eq(user.active, true))
)

// Query that depends on active users
const activeUserPosts = createLiveQueryCollection((q) =>
q
.from({ user: activeUsers })
.join({ post: postsCollection }, ({ user, post }) =>
eq(user.id, post.userId)
)
.select(({ user, post }) => ({
userName: user.name,
postTitle: post.title,
}))
)

재사용 가능한 쿼리 정의

Query 클래스를 사용하여 재사용 가능한 쿼리 정의를 만들 수 있습니다. 애플리케이션 전체에서 동일한 쿼리 빌더 인스턴스를 여러 번 재사용하려는 복잡한 쿼리를 작성할 때 유용합니다.

import { Query, eq } from '@tanstack/db'

// Create a reusable query builder
const userQuery = new Query()
.from({ user: usersCollection })
.where(({ user }) => eq(user.active, true))

// Use it in different contexts
const activeUsers = createLiveQueryCollection({
query: userQuery.select(({ user }) => ({
id: user.id,
name: user.name,
}))
})

// Or as a subquery
const userPosts = createLiveQueryCollection((q) =>
q
.from({ activeUser: userQuery })
.join({ post: postsCollection }, ({ activeUser, post }) =>
eq(activeUser.id, post.userId)
)
)

재사용 가능한 콜백 함수

재사용 가능한 쿼리 로직을 만드는 것은 코드 구성과 유지 관리성을 향상하는 일반적인 패턴입니다. 권장되는 접근 방식은 Ref<T> 유형의 콜백 함수를 사용하는 것이며, QueryBuilder 인스턴스에 직접 유형을 지정하려고 하지 않는 것입니다.

Ref<MyType>을 사용하여 재사용 가능한 필터 및 변환 함수를 만듭니다:

import type { Ref } from '@tanstack/db'
import { eq, gt, and } from '@tanstack/db'

// Create reusable filter callbacks
const isActiveUser = ({ user }: { user: Ref<User> }) =>
eq(user.active, true)

const isAdultUser = ({ user }: { user: Ref<User> }) =>
gt(user.age, 18)

const isActiveAdult = ({ user }: { user: Ref<User> }) =>
and(isActiveUser({ user }), isAdultUser({ user }))

// Use them in queries - they work seamlessly with .where()
const activeAdults = createCollection(liveQueryCollectionOptions({
query: (q) =>
q
.from({ user: usersCollection })
.where(isActiveUser)
.where(isAdultUser)
.select(({ user }) => ({
id: user.id,
name: user.name,
age: user.age,
}))
}))

콜백 시그니처 ({ user }: { user: Ref<User> }) => Expression.where()가 요구하는 형식과 정확히 일치하므로 유형 안전성과 조합 가능성을 제공합니다.

여러 필터 연결

여러 재사용 가능한 필터를 연결할 수 있습니다:

import { useLiveQuery } from '@tanstack/react-db'

const { data } = useLiveQuery({
query: (q) =>
q
.from({ item: itemsCollection })
.where(({ item }) => eq(item.id, 1))
.where(activeItemFilter) // Reusable filter 1
.where(verifiedItemFilter) // Reusable filter 2
.select(({ item }) => ({ ...item })),
})

다른 별칭과 함께 사용

이 패턴은 모든 테이블 별칭에서 작동합니다:

const activeFilter = ({ item }: { item: Ref<Item> }) =>
eq(item.active, true)

// Works with any alias name
const query1 = new Query()
.from({ item: itemsCollection })
.where(activeFilter)

const query2 = new Query()
.from({ i: itemsCollection })
.where(({ i }) => activeFilter({ item: i })) // Map the alias

여러 테이블을 사용하는 콜백

조인이 포함된 쿼리에서는 여러 ref를 허용하는 콜백을 만듭니다:

const isHighValueCustomer = ({ user, order }: {
user: Ref<User>
order: Ref<Order>
}) => and(
eq(user.active, true),
gt(order.amount, 1000)
)

// Use directly in where clause
const highValueCustomers = createCollection(liveQueryCollectionOptions({
query: (q) =>
q
.from({ user: usersCollection })
.join({ order: ordersCollection }, ({ user, order }) =>
eq(user.id, order.userId)
)
.where(isHighValueCustomer)
.select(({ user, order }) => ({
userName: user.name,
orderAmount: order.amount,
}))
}))

QueryBuilder 유형을 사용하지 않는 이유

QueryBuilder을 허용하고 반환하는 함수를 만들고 싶을 수 있습니다:

// ❌ Not recommended - overly complex typing
const applyFilters = <T extends QueryBuilder<unknown>>(query: T): T => {
return query.where(({ item }) => eq(item.active, true))
}

이 접근 방식에는 다음과 같은 몇 가지 문제가 있습니다:

  1. 복잡한 유형: QueryBuilder<T> 제네릭은 기본 스키마, 현재 스키마, 조인, 결과 유형 등을 포함한 전체 쿼리 컨텍스트를 나타냅니다.
  2. 유형 추론: 유형이 모든 메서드 호출마다 변경되므로 수동으로 지정하기가 현실적이지 않습니다.
  3. 제한된 유연성: 여러 필터를 조합하거나 다른 테이블 별칭과 함께 사용하기 어렵습니다.

대신 .where(), .select() 및 기타 쿼리 메서드와 직접 작동하는 콜백 함수를 사용합니다.

재사용 가능한 Select 변환

재사용 가능한 select 프로젝션도 만들 수 있습니다:

const basicUserInfo = ({ user }: { user: Ref<User> }) => ({
id: user.id,
name: user.name,
email: user.email,
})

const userWithStats = ({ user }: { user: Ref<User> }) => ({
...basicUserInfo({ user }),
isAdult: gt(user.age, 18),
isActive: eq(user.active, true),
})

const users = createLiveQueryCollection((q) =>
q
.from({ user: usersCollection })
.select(userWithStats)
)

이 접근 방식은 쿼리 로직을 애플리케이션 전체에서 더 모듈화되고 테스트 가능하며 재사용 가능하게 만듭니다.

반응형 효과(createEffect)

라이브 쿼리 컬렉션은 쿼리 결과를 구체화하여 구독하고 반복할 수 있는 컬렉션으로 만드는 반면, 반응형 효과를 사용하면 전체 결과 집합을 구체화하지 않고 쿼리 결과의 변경에 응답할 수 있습니다. 효과는 쿼리 결과에 행이 들어오거나, 나가거나, 업데이트될 때 콜백을 실행합니다.

데이터가 변경될 때마다 알림 전송, 외부 시스템과 동기화, AI 응답 생성, 카운터 업데이트 등의 부작용을 트리거할 때 유용합니다.

효과와 라이브 쿼리 컬렉션 중 선택

사용 사례접근 방식
UI에 쿼리 결과 표시라이브 쿼리 컬렉션 + useLiveQuery
변경에 반응(부작용)createEffect / useLiveQueryEffect
결과 집합에 새로 들어오는 항목 추적createEffectonEnter
결과 집합에서 나가는 항목 모니터링createEffectonExit
결과 집합 내 업데이트에 응답createEffectonUpdate

기본 사용법

import { createEffect, eq } from '@tanstack/db'

const effect = createEffect({
query: (q) =>
q
.from({ msg: messagesCollection })
.where(({ msg }) => eq(msg.role, 'user')),
onEnter: async (event) => {
console.log('New user message:', event.value)
await generateResponse(event.value)
},
})

// Later: stop the effect
await effect.dispose()

구성

createEffectEffectConfig 객체를 허용합니다:

const effect = createEffect({
id: 'my-effect', // Optional: auto-generated if not provided
query: (q) => q.from(...), // Query to watch
onEnter: (event, ctx) => { ... }, // Per-enter callback
onUpdate: (event, ctx) => { ... }, // Per-update callback
onExit: (event, ctx) => { ... }, // Per-exit callback
onBatch: (events, ctx) => { ... }, // Full batch callback
onError: (error, event) => { ... }, // Callback error handler
onSourceError: (error) => { ... }, // Source collection error callback
skipInitial: false, // Skip deltas during initial load
})
옵션타입설명
idstring(선택 사항)디버깅/추적을 위한 식별자입니다. 제공되지 않으면 live-query-effect-{n}로 자동 생성됩니다.
queryQueryBuilder 또는 함수감시할 쿼리입니다. 라이브 쿼리 컬렉션과 동일한 빌더 함수 또는 QueryBuilder 인스턴스를 허용합니다.
onEnter(event, ctx) => void | Promise<void>(선택 사항)쿼리 결과에 들어오는 각 행마다 한 번 호출됩니다.
onUpdate(event, ctx) => void | Promise<void>(선택 사항)쿼리 결과 내에서 업데이트되는 각 행마다 한 번 호출됩니다.
onExit(event, ctx) => void | Promise<void>(선택 사항)쿼리 결과에서 나가는 각 행마다 한 번 호출됩니다.
onBatch(events, ctx) => void | Promise<void>(선택 사항)필터링되지 않은 전체 델타 이벤트 배치와 함께 그래프 실행마다 한 번 호출됩니다.
onError(error, event) => void(선택 사항)onEnter, onUpdate, onExit 또는 onBatch에서 오류가 발생하거나 거부될 때 호출됩니다.
onSourceError(error) => void(선택 사항)소스 컬렉션이 오류 또는 정리된 상태에 들어갈 때 호출됩니다. 호출된 후 효과가 자동으로 삭제됩니다. 제공되지 않으면 오류가 console.error에 기록됩니다.
skipInitialboolean(선택 사항)true인 경우 초기 데이터 로드의 델타가 억제됩니다. 이후 변경 사항만 핸들러를 실행합니다. 기본값은 false입니다.

델타 이벤트

각 델타 이벤트는 쿼리 결과 내 단일 행 변경을 설명합니다:

interface DeltaEvent<TRow, TKey> {
type: 'enter' | 'exit' | 'update'
key: TKey
value: TRow
previousValue?: TRow // Only present for 'update' events
}
이벤트 유형의미valuepreviousValue
enter행이 쿼리 결과에 들어옴새 행
exit행이 쿼리 결과에서 나감나가는 행
update행이 변경되었지만 결과에 계속 남음새 행변경 전 행

이름이 지정된 콜백

관심 있는 쿼리 결과 전환에 해당하는 콜백을 사용합니다:

// Only new rows entering the result
createEffect({ onEnter: (event) => { ... }, ... })

// Only rows leaving the result
createEffect({ onExit: (event) => { ... }, ... })

// Only rows that changed but stayed in the result
createEffect({ onUpdate: (event) => { ... }, ... })

// Inspect the full mixed batch for a graph run
createEffect({ onBatch: (events) => { ... }, ... })

행별 콜백과 onBatch

행별 콜백, onBatch 또는 둘 다 제공할 수 있습니다:

createEffect({
query: (q) => q.from({ user: usersCollection }),

onEnter: (event, ctx) => {
console.log(`enter: ${event.key}`)
},

onExit: (event, ctx) => {
console.log(`exit: ${event.key}`)
},

onBatch: (events, ctx) => {
console.log(`Batch of ${events.length} events`)
},
})

두 핸들러 모두 EffectContext을 받습니다:

interface EffectContext {
effectId: string // The effect's ID
signal: AbortSignal // Aborted when effect.dispose() is called
}

signal은 효과가 삭제될 때 진행 중인 비동기 작업을 취소하는 데 유용합니다:

createEffect({
query: (q) => q.from({ task: tasksCollection }),
onEnter: async (event, ctx) => {
const result = await fetch('/api/process', {
method: 'POST',
body: JSON.stringify(event.value),
signal: ctx.signal, // Cancelled on dispose
})
// ...
},
})

초기 데이터 건너뛰기

기본적으로 효과는 초기 로드를 포함한 모든 데이터를 처리합니다. 초기 동기화 후 발생하는 변경 사항에만 응답하려면 skipInitial: true을 설정합니다:

// Only react to NEW messages, not existing ones
const effect = createEffect({
query: (q) =>
q.from({ msg: messagesCollection })
.where(({ msg }) => eq(msg.role, 'user')),
skipInitial: true,
onEnter: async (event) => {
await sendNotification(event.value)
},
})

오류 처리

onEnter, onUpdate, onExit 또는 onBatch에서 발생한 오류(동기 또는 비동기)는 포착되어 onError로 전달됩니다. onError이 제공되지 않으면 console.error에 기록됩니다:

createEffect({
query: (q) => q.from({ order: ordersCollection }),
onEnter: async (event) => {
await processOrder(event.value)
},
onError: (error, event) => {
console.error(`Failed to process order ${event.key}:`, error)
reportToErrorTracker(error)
},
})

소스 컬렉션이 오류 또는 정리된 상태에 들어가면 효과가 자동으로 삭제됩니다. 이를 처리하려면 onSourceError를 사용합니다:

createEffect({
query: (q) => q.from({ data: dataCollection }),
onBatch: (events) => { ... },
onSourceError: (error) => {
console.warn('Data source failed, effect disposed:', error.message)
},
})

삭제

createEffectEffect 핸들을 반환하며, 이 핸들에는 dispose() 메서드가 있습니다:

const effect = createEffect({ ... })

// Check if disposed
console.log(effect.disposed) // false

// Dispose: unsubscribes from sources, aborts the signal,
// and waits for in-flight async handlers to settle
await effect.dispose()

console.log(effect.disposed) // true

dispose()은 멱등적이므로 여러 번 호출해도 안전합니다. 모든 진행 중인 비동기 핸들러가(Promise.allSettled를 통해) 완료되면 resolve되는 프로미스를 반환합니다.

쿼리 기능

Effects는 전체 쿼리 시스템을 지원합니다. 라이브 쿼리 컬렉션에서 수행할 수 있는 모든 작업을 Effects에서도 수행할 수 있습니다:

// Joins
createEffect({
query: (q) =>
q
.from({ user: usersCollection })
.join({ post: postsCollection }, ({ user, post }) =>
eq(user.id, post.userId)
)
.select(({ user, post }) => ({
userName: user.name,
postTitle: post.title,
})),
onEnter: (event) => {
console.log(`${event.value.userName} published "${event.value.postTitle}"`)
},
})

// Filters
createEffect({
query: (q) =>
q
.from({ user: usersCollection })
.where(({ user }) => eq(user.role, 'admin')),
onEnter: (event) => {
console.log(`New admin: ${event.value.name}`)
},
})

// OrderBy + Limit (top-K window)
createEffect({
query: (q) =>
q
.from({ score: scoresCollection })
.orderBy(({ score }) => score.points, 'desc')
.limit(10),
onBatch: (events) => {
// Fires once per graph run with all enter/update/exit events
for (const event of events) {
console.log(`${event.type}: ${event.value.name} (${event.value.points} pts)`)
}
},
})

orderBylimit와 함께 사용하면 Effects가 상위 K개 윈도우를 추적합니다. 항목이 윈도우에 들어올 때 enter 이벤트를 받고, 항목이 밀려날 때 exit 이벤트를 받습니다.

트랜잭션 병합

단일 트랜잭션 내에서 여러 변경 사항이 발생하면 Effects가 이를 하나의 배치로 병합합니다. 즉, 개별 쓰기마다 한 번씩이 아니라 해당 트랜잭션의 모든 변경 사항과 함께 핸들러가 한 번 호출됩니다:

createEffect({
query: (q) => q.from({ item: itemsCollection }),
onBatch: (events) => {
// If 3 items are inserted in one transaction,
// this fires once with all 3 events
console.log(`${events.length} items added`)
},
})

React와 함께 사용

useLiveQueryEffect 훅은 Effect 수명 주기를 자동으로 관리합니다. 마운트 시 생성하고, 언마운트 시 정리하며, Effect 종속성이 변경되면 다시 생성합니다:

import { useLiveQueryEffect } from '@tanstack/react-db'
import { eq } from '@tanstack/db'

function ChatComponent({ channelId }: { channelId: string }) {
useLiveQueryEffect(
{
query: (q) =>
q
.from({ msg: messagesCollection })
.where(({ msg }) => eq(msg.channelId, channelId)),
skipInitial: true,
onEnter: async (event) => {
await playNotificationSound()
},
},
[channelId] // Recreate effect when channelId changes
)

return <div>...</div>
}

두 번째 인수는 여전히 Effect 수명 주기를 위한 React 스타일 종속성 배열입니다. 이는 useLiveQuery ID와 별개입니다. React 라이브 쿼리 훅은 기본적으로 구조화된 IR에서 ID를 파생하며, 불투명 쿼리 또는 핫 패스 쿼리에만 queryKey를 사용합니다.

전체 예제

다음은 주문 상태 변경을 모니터링하고 알림을 보내는 Effect를 보여주는 더 완전한 예제입니다:

import { createEffect, eq } from '@tanstack/db'

const orderEffect = createEffect({
id: 'order-status-monitor',
query: (q) =>
q
.from({ order: ordersCollection })
.join({ customer: customersCollection }, ({ order, customer }) =>
eq(order.customerId, customer.id)
)
.where(({ order }) => eq(order.status, 'shipped'))
.select(({ order, customer }) => ({
orderId: order.id,
customerEmail: customer.email,
trackingNumber: order.trackingNumber,
})),
skipInitial: true,

onEnter: async (event, ctx) => {
await sendShipmentEmail({
to: event.value.customerEmail,
orderId: event.value.orderId,
tracking: event.value.trackingNumber,
signal: ctx.signal,
})
},

onError: (error, event) => {
console.error(`Failed to notify for order ${event.key}:`, error)
},

onSourceError: (error) => {
alertOpsTeam('Order monitoring effect failed', error)
},
})

// On application shutdown
await orderEffect.dispose()

표현식 함수 참조

쿼리 시스템은 데이터 필터링, 변환 및 집계를 위한 포괄적인 함수 집합을 제공합니다.

비교 연산자

eq(left, right)

동등성 비교:

eq(user.id, 1)
eq(user.name, 'John')

gt(left, right), gte(left, right), lt(left, right), lte(left, right)

숫자, 문자열 및 날짜 비교:

gt(user.age, 18)
gte(user.salary, 50000)
lt(user.createdAt, new Date('2024-01-01'))
lte(user.rating, 5)

inArray(value, array)

값이 배열에 포함되어 있는지 확인합니다:

inArray(user.id, [1, 2, 3])
inArray(user.role, ['admin', 'moderator'])

like(value, pattern), ilike(value, pattern)

문자열 패턴 일치:

like(user.name, 'John%')    // Case-sensitive
ilike(user.email, '%@gmail.com') // Case-insensitive

isUndefined(value), isNull(value)

누락된 값과 null 값을 확인합니다:

// Check if a property is missing/undefined
isUndefined(user.profile)

// Check if a value is explicitly null
isNull(user.profile)

이러한 함수는 조인 및 선택적 속성을 사용할 때 특히 중요합니다. 다음을 구분하기 때문입니다:

  • undefined: 속성이 없거나 존재하지 않습니다
  • null: 속성이 존재하지만 명시적으로 null로 설정되어 있습니다

조인을 사용하는 예제:

// Find users without a matching profile (left join resulted in undefined)
const usersWithoutProfiles = createLiveQueryCollection((q) =>
q
.from({ user: usersCollection })
.leftJoin(
{ profile: profilesCollection },
({ user, profile }) => eq(user.id, profile.userId)
)
.where(({ profile }) => isUndefined(profile))
)

// Find users with explicitly null bio field
const usersWithNullBio = createLiveQueryCollection((q) =>
q
.from({ user: usersCollection })
.where(({ user }) => isNull(user.bio))
)

논리 연산자

and(...conditions)

AND 논리로 조건을 결합합니다:

and(
eq(user.active, true),
gt(user.age, 18),
eq(user.role, 'user')
)

or(...conditions)

OR 논리로 조건을 결합합니다:

or(
eq(user.role, 'admin'),
eq(user.role, 'moderator')
)

not(condition)

조건을 부정합니다:

not(eq(user.active, false))

문자열 함수

upper(value), lower(value)

대소문자를 변환합니다:

upper(user.name)  // 'JOHN'
lower(user.email) // 'john@example.com'

length(value)

문자열 또는 배열의 길이를 가져옵니다:

length(user.name)     // String length
length(user.tags) // Array length

concat(...values)

문자열을 연결합니다:

concat(user.firstName, ' ', user.lastName)
concat('User: ', user.name, ' (', user.id, ')')

수학 함수

add(left, right)

두 수를 더합니다:

add(user.salary, user.bonus)

subtract(left, right)

두 수를 뺍니다:

subtract(user.salary, user.deductions)

multiply(left, right)

두 수를 곱합니다:

multiply(item.price, item.quantity)

divide(left, right)

두 수를 나눕니다(영으로 나누면 null를 반환합니다):

divide(order.total, order.itemCount)

orderBy의 계산된 열

orderBy에서 수학 함수를 직접 사용하여 계산된 값으로 정렬할 수 있습니다. 여러 요소를 결합하는 순위 알고리즘에 유용합니다:

import { subtract, multiply, divide } from '@tanstack/db'

// HN-style ranking: balance rating with recency
// Date.now() is captured when this query is created. Recreate the query if
// you need the recency score to advance as time passes.
const rankedRecipes = createLiveQueryCollection((q) =>
q
.from({ r: recipesCollection })
.orderBy(
({ r }) =>
subtract(
multiply(r.rating, r.timesMade), // weighted rating
divide(
subtract(Date.now(), r.lastMadeAt), // time since last made
3600000 * 24 // convert ms to days
)
),
'desc'
)
.limit(20)
)

참고: orderBy에서 limit()와 함께 계산된 표현식을 사용하면 지연 로딩 최적화가 건너뛰어집니다(일치하는 모든 데이터를 먼저 로드한 다음 정렬합니다). 이 점이 중요한 대규모 컬렉션에서는 순위 점수를 저장된 필드로 미리 계산하는 방법을 고려할 수 있습니다.

유틸리티 함수

coalesce(...values)

처음으로 null이 아니거나 정의되지 않은 값을 반환합니다:

coalesce(user.displayName, user.name, 'Unknown')

저장된 값이 null 또는 undefined일 때 표시 대체값으로 유용합니다:

.select(({ document }) => ({
...document,
displayTitle: coalesce(document.title, 'Untitled document'),
}))

빈 문자열과 같은 다른 값도 누락된 것으로 처리해야 한다면, caseWhen를 사용하여 해당 조건을 명시적으로 표현합니다.

caseWhen(condition, value, ...)

SQL CASE WHEN와 유사하게, 처음으로 일치하는 조건의 값을 반환합니다. 인수는 조건/값 쌍으로 제공되며, 그 뒤에 선택적 기본값을 지정할 수 있습니다:

caseWhen(gt(user.age, 65), 'senior', gt(user.age, 18), 'adult', 'minor')

일치하는 조건이 없고 기본값도 제공되지 않으면 스칼라 표현식은 null를 반환합니다.

계산된 필드에 caseWhen만으로 나타낼 수 없고 coalesce이 필요한 조건부 로직이 있을 때 사용합니다. 예를 들어 null/undefined 제목과 빈 문자열 제목 모두에 대한 대체값을 표시할 수 있습니다:

.select(({ document }) => ({
...document,
displayTitle: caseWhen(
eq(coalesce(document.title, ''), ''),
'Untitled document',
document.title,
),
}))

caseWhenselect, where와 같은 표현식 컨텍스트에서도 사용할 수 있습니다, orderBy, groupBy, having 및 동등성 조인 피연산자에서도 사용할 수 있습니다.

집계 함수

count(value)

null이 아닌 값을 셉니다:

count(user.id)        // Count all users
count(user.postId) // Count users with posts

sum(value)

숫자 값을 합산합니다:

sum(order.amount)
sum(user.salary)

avg(value)

평균을 계산합니다:

avg(user.salary)
avg(order.amount)

min(value), max(value)

최솟값과 최댓값을 찾습니다:

min(user.salary)
max(order.amount)

함수 합성

함수는 합성하고 연결할 수 있습니다:

// Complex condition
and(
eq(user.active, true),
or(
gt(user.age, 25),
eq(user.role, 'admin')
),
not(inArray(user.id, bannedUserIds))
)

// Complex transformation
concat(
upper(user.firstName),
' ',
upper(user.lastName),
' (',
user.id,
')'
)

// Complex aggregation
avg(add(user.salary, coalesce(user.bonus, 0)))

함수형 변형

함수형 변형 API는 표준 API의 대안으로, 복잡한 변환에 더 많은 유연성을 제공합니다. 함수형 변형에서는 콜백 함수에 작업을 수행하기 위해 실행되는 실제 코드가 포함되므로 JavaScript의 모든 기능을 활용할 수 있습니다.

[!WARNING] 함수형 변형 API는 쿼리 옵티마이저로 최적화할 수 없으며 컬렉션 인덱스를 사용할 수 없습니다. 표준 API로 충분하지 않은 드문 경우에 사용하도록 설계되었습니다.

함수형 Select

[!WARNING] fn.select()groupBy()와 함께 사용할 수 없습니다. groupBy 연산자는 계산할 집계 함수를 확인하기 위해 select 절을 정적으로 분석해야 하지만, 불투명한 JavaScript 함수로는 이를 수행할 수 없습니다. 그룹화된 쿼리에는 표준 .select() API를 사용합니다.

JavaScript 로직을 사용한 복잡한 변환에는 fn.select()를 사용합니다:

const userProfiles = createLiveQueryCollection((q) =>
q
.from({ user: usersCollection })
.fn.select((row) => ({
id: row.user.id,
displayName: `${row.user.firstName} ${row.user.lastName}`,
salaryTier: row.user.salary > 100000 ? 'senior' : 'junior',
emailDomain: row.user.email.split('@')[1],
isHighEarner: row.user.salary > 75000,
}))
)

함수형 Where

복잡한 필터링 로직에는 fn.where()를 사용합니다:

const specialUsers = createLiveQueryCollection((q) =>
q
.from({ user: usersCollection })
.fn.where((row) => {
const user = row.user
return user.active &&
(user.age > 25 || user.role === 'admin') &&
user.email.includes('@company.com')
})
)

함수형 Having

복잡한 집계 필터링에는 fn.having()를 사용합니다:

const highValueCustomers = createLiveQueryCollection((q) =>
q
.from({ order: ordersCollection })
.groupBy(({ order }) => order.customerId)
.select(({ order }) => ({
customerId: order.customerId,
totalSpent: sum(order.amount),
orderCount: count(order.id),
}))
.fn.having(({ $selected }) => {
return $selected.totalSpent > 1000 && $selected.orderCount >= 3
})
)

복잡한 변환

함수형 변형은 복잡한 데이터 변환에 특히 적합합니다:

const userProfiles = createLiveQueryCollection((q) =>
q
.from({ user: usersCollection })
.fn.select((row) => {
const user = row.user
const fullName = `${user.firstName} ${user.lastName}`.trim()
const emailDomain = user.email.split('@')[1]
const ageGroup = user.age < 25 ? 'young' : user.age < 50 ? 'adult' : 'senior'

return {
userId: user.id,
displayName: fullName || user.name,
contactInfo: {
email: user.email,
domain: emailDomain,
isCompanyEmail: emailDomain === 'company.com'
},
demographics: {
age: user.age,
ageGroup: ageGroup,
isAdult: user.age >= 18
},
status: user.active ? 'active' : 'inactive',
profileStrength: fullName && user.email && user.age ? 'complete' : 'incomplete'
}
})
)

타입 추론

함수형 변형은 완전한 TypeScript 지원을 유지합니다:

const processedUsers = createLiveQueryCollection((q) =>
q
.from({ user: usersCollection })
.fn.select((row): ProcessedUser => ({
id: row.user.id,
name: row.user.name.toUpperCase(),
age: row.user.age,
ageGroup: row.user.age < 25 ? 'young' : row.user.age < 50 ? 'adult' : 'senior',
}))
)

함수형 변형을 사용할 시점

다음이 필요할 때 함수형 변형을 사용합니다:

  • 기본 제공 함수로 표현할 수 없는 복잡한 JavaScript 로직
  • 외부 라이브러리 또는 유틸리티와의 통합
  • 사용자 지정 작업을 위한 JavaScript의 모든 기능

함수형 변형의 콜백은 표준 API가 선언적 표현식을 사용하는 것과 달리 실제로 실행되는 JavaScript 함수입니다. 따라서 로직을 완전히 제어할 수 있지만 최적화 기회가 줄어드는 대가가 따릅니다.

그러나 더 나은 성능과 최적화 기회를 제공하므로 가능한 경우 표준 API를 우선 사용해야 합니다.