본문으로 건너뛰기

TanStack DB 뮤테이션

TanStack DB는 자동 상태 관리를 통해 낙관적 업데이트를 지원하는 강력한 뮤테이션 시스템을 제공합니다. 이 시스템은 낙관적 뮤테이션 → 백엔드 영속화 → 동기화 → 확인된 상태 패턴을 중심으로 구축됩니다. 이를 통해 데이터 일관성을 유지하고 이해하기 쉬우면서도 매우 반응성이 뛰어난 사용자 경험을 제공합니다.

로컬 변경 사항은 낙관적 상태로 즉시 적용된 다음 백엔드에 영속화되며, 동기화되어 돌아오면 낙관적 상태가 확인된 서버 상태로 대체됩니다.

// Define a collection with a mutation handler
const todoCollection = createCollection({
id: "todos",
onUpdate: async ({ transaction }) => {
const mutation = transaction.mutations[0]
await api.todos.update(mutation.original.id, mutation.changes)
},
})

// Apply an optimistic update
todoCollection.update(todo.id, (draft) => {
draft.completed = true
})

이 패턴은 클라이언트를 넘어 서버까지 포함하도록 Redux/Flux 단방향 데이터 흐름을 확장합니다:

낙관적 상태의 즉각적인 내부 루프가 먼저 실행되고, 시간이 지나면서 서버에 영속화하고 업데이트된 서버 상태를 컬렉션에 다시 동기화하는 더 느린 외부 루프가 이를 대체합니다.

간소화된 뮤테이션과 기존 접근 방식 비교

TanStack DB의 뮤테이션 시스템은 기존 접근 방식에서 낙관적 업데이트에 필요했던 많은 보일러플레이트를 제거합니다. 다음 비교에서 차이를 확인할 수 있습니다:

이전(TanStack Query를 사용한 수동 낙관적 업데이트):

const addTodoMutation = useMutation({
mutationFn: async (newTodo) => api.todos.create(newTodo),
onMutate: async (newTodo) => {
await queryClient.cancelQueries({ queryKey: ['todos'] })
const previousTodos = queryClient.getQueryData(['todos'])
queryClient.setQueryData(['todos'], (old) => [...(old || []), newTodo])
return { previousTodos }
},
onError: (err, newTodo, context) => {
queryClient.setQueryData(['todos'], context.previousTodos)
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})

이후(TanStack DB):

const todoCollection = createCollection(
queryCollectionOptions({
queryKey: ['todos'],
queryFn: async () => api.todos.getAll(),
getKey: (item) => item.id,
schema: todoSchema,
onInsert: async ({ transaction }) => {
await Promise.all(
transaction.mutations.map((mutation) =>
api.todos.create(mutation.modified)
)
)
},
})
)

// Simple mutation - no boilerplate!
todoCollection.insert({
id: crypto.randomUUID(),
text: '🔥 Make app faster',
completed: false,
})

이점:

  • ✅ 자동 낙관적 업데이트
  • ✅ 오류 발생 시 자동 롤백
  • ✅ 수동 캐시 조작 불필요
  • ✅ 타입 안전 뮤테이션

목차

뮤테이션 접근 방식

TanStack DB는 다양한 뮤테이션 접근 방식을 제공하며, 각각 서로 다른 사용 사례에 적합합니다:

컬렉션 수준 뮤테이션

컬렉션 수준 뮤테이션(insert, update, delete)은 단일 컬렉션의 직접 상태 조작을 위해 설계되었습니다. 변경 사항을 적용하는 가장 간단한 방법이며, 단순한 CRUD 작업에 적합합니다.

// Direct state change
todoCollection.update(todoId, (draft) => {
draft.completed = true
draft.completedAt = new Date()
})

다음과 같은 경우 컬렉션 수준 뮤테이션을 사용합니다:

  • 단일 컬렉션에서 간단한 CRUD 작업을 수행합니다
  • 상태 변경이 단순하고 서버에 저장될 내용과 일치합니다

metadata를 사용하여 이러한 작업에 주석을 추가하고 핸들러에서 동작을 사용자 지정할 수 있습니다:

// Annotate with metadata
todoCollection.update(
todoId,
{ metadata: { intent: 'complete' } },
(draft) => {
draft.completed = true
}
)

// Use metadata in handler
onUpdate: async ({ transaction }) => {
const mutation = transaction.mutations[0]

if (mutation.metadata?.intent === 'complete') {
await Promise.all(
transaction.mutations.map((mutation) =>
api.todos.complete(mutation.original.id)
)
)
} else {
await Promise.all(
transaction.mutations.map((mutation) =>
api.todos.update(mutation.original.id, mutation.changes)
)
)
}
}

의도 기반 뮤테이션과 사용자 지정 액션

더 복잡한 시나리오에서는 createOptimisticAction를 사용하여 특정 사용자 작업을 포착하는 의도 기반 뮤테이션을 만듭니다.

// Intent: "like this post"
const likePost = createOptimisticAction<string>({
onMutate: (postId) => {
// Optimistic guess at the change
postCollection.update(postId, (draft) => {
draft.likeCount += 1
draft.likedByMe = true
})
},
mutationFn: async (postId) => {
// Send the intent to the server
await api.posts.like(postId)
// Server determines actual state changes
await postCollection.utils.refetch()
},
})

// Use it.
likePost(postId)

다음과 같은 경우 사용자 지정 액션을 사용합니다:

  • 단일 트랜잭션에서 여러 컬렉션을 뮤테이션해야 합니다
  • 낙관적 변경이 서버가 데이터를 변환하는 방식을 추측한 것입니다
  • 정확한 상태 변경 대신 백엔드에 사용자 의도를 전송하려고 합니다
  • 서버가 복잡한 로직, 계산 또는 부수 효과를 수행합니다
  • 특정 작업을 포착하는 깔끔하고 재사용 가능한 뮤테이션을 원합니다

사용자 지정 액션은 애플리케이션에서 특정 유형의 뮤테이션을 이름이 지정된 작업으로 포착하는 가장 깔끔한 방법을 제공합니다. 컬렉션 수준 뮤테이션의 메타데이터를 사용하여 유사한 결과를 얻을 수도 있지만, 사용자 지정 액션을 사용하면 의도가 명확해지고 관련 로직을 함께 유지할 수 있습니다.

각 방식을 사용하는 경우:

  • 컬렉션 수준 뮤테이션(collection.update): 단일 컬렉션의 간단한 CRUD 작업
  • createOptimisticAction: 의도 기반 작업, 여러 컬렉션의 뮤테이션, 즉시 커밋
  • 뮤테이션 시스템 우회: 다시 작성하지 않고 기존 뮤테이션 로직을 사용합니다

뮤테이션 시스템 우회

기존 시스템에 이미 뮤테이션 로직이 있고 이를 다시 작성하고 싶지 않다면, TanStack DB의 뮤테이션 시스템을 완전히 우회하고 기존 패턴을 사용할 수 있습니다.

이 접근 방식에서는 기존 로직을 사용하여 평소처럼 서버에 쓰고, 컬렉션의 다시 가져오기 또는 데이터 동기화 메커니즘을 사용하여 서버 쓰기가 완료될 때까지 기다립니다. 동기화가 완료되면 컬렉션에 업데이트된 서버 데이터가 포함되므로 새 상태를 렌더링하고, 로딩 표시기를 숨기고, 성공 메시지를 표시하고, 새 페이지로 이동하는 등의 작업을 수행할 수 있습니다.

// Call your backend directly with your existing logic
const handleUpdateTodo = async (todoId, changes) => {
await api.todos.update(todoId, changes)

// Wait for the server change to load into the collection
await todoCollection.utils.refetch()

// Now you know the new data is loaded and can render it or hide loaders
}

// With Electric
const handleUpdateTodo = async (todoId, changes) => {
const { txid } = await api.todos.update(todoId, changes)

// Wait for this specific transaction to sync into the collection
await todoCollection.utils.awaitTxId(txid)

// Now the server change is loaded and you can update UI accordingly
}

다음과 같은 경우 이 접근 방식을 사용합니다:

  • 다시 작성하고 싶지 않은 기존 뮤테이션 로직이 있습니다
  • 현재 뮤테이션 패턴을 사용하는 데 익숙합니다
  • TanStack DB를 쿼리와 상태 관리에만 사용하려고 합니다

변경 사항을 다시 동기화하는 방법:

  • QueryCollection: collection.utils.refetch()을 사용하여 수동으로 다시 가져와 서버에서 데이터를 다시 로드합니다
  • ElectricCollection: collection.utils.awaitTxId(txid)를 사용하여 특정 트랜잭션이 동기화될 때까지 기다립니다
  • 기타 동기화 시스템: 동기화 메커니즘이 컬렉션을 업데이트할 때까지 기다립니다

뮤테이션 수명 주기

뮤테이션 수명 주기는 모든 뮤테이션 유형에서 일관된 패턴을 따릅니다:

  1. 낙관적 상태 적용: 뮤테이션이 낙관적 상태로 로컬 컬렉션에 즉시 적용됩니다
  2. 핸들러 호출: 적절한 핸들러(즉, mutationFn 또는 컬렉션 핸들러(onInsert, onUpdate, onDelete))가 호출되어 변경 사항을 영속화합니다
  3. 백엔드 영속화: 핸들러가 백엔드에 데이터를 영속화합니다
  4. 다시 동기화: 핸들러가 서버 쓰기가 컬렉션에 다시 동기화되도록 합니다
  5. 낙관적 상태 제거: 동기화가 완료되면 낙관적 상태가 확인된 서버 상태로 대체됩니다
// Step 1: Optimistic state applied immediately
todoCollection.update(todo.id, (draft) => {
draft.completed = true
})
// UI updates instantly with optimistic state

// Step 2-3: onUpdate handler persists to backend
// Step 4: Handler waits for sync back
// Step 5: Optimistic state replaced by server state

영속화 중 핸들러에서 오류가 발생하면 낙관적 상태가 자동으로 롤백됩니다.

컬렉션 쓰기 작업

컬렉션은 세 가지 핵심 쓰기 작업 insert, update, delete을 지원합니다. 각 작업은 낙관적 상태를 즉시 적용하고 해당 작업 핸들러를 트리거합니다.

삽입

컬렉션에 새 항목을 추가합니다:

// Insert a single item
todoCollection.insert({
id: "1",
text: "Buy groceries",
completed: false
})

// Insert multiple items
todoCollection.insert([
{ id: "1", text: "Buy groceries", completed: false },
{ id: "2", text: "Walk dog", completed: false },
])

// Insert with metadata
todoCollection.insert(
{ id: "1", text: "Custom item", completed: false },
{ metadata: { source: "import" } }
)

// Insert without optimistic updates
todoCollection.insert(
{ id: "1", text: "Server-validated item", completed: false },
{ optimistic: false }
)

반환값: 뮤테이션 수명 주기를 추적하는 데 사용할 수 있는 Transaction 객체입니다.

업데이트

불변 초안 패턴을 사용하여 기존 항목을 수정합니다:

// Update a single item
todoCollection.update(todo.id, (draft) => {
draft.completed = true
})

// Update multiple items
todoCollection.update([todo1.id, todo2.id], (drafts) => {
drafts.forEach((draft) => {
draft.completed = true
})
})

// Update with metadata
todoCollection.update(
todo.id,
{ metadata: { reason: "user update" } },
(draft) => {
draft.text = "Updated text"
}
)

// Update without optimistic updates
todoCollection.update(
todo.id,
{ optimistic: false },
(draft) => {
draft.status = "server-validated"
}
)

매개변수:

  • key 또는 keys: 업데이트할 항목 키입니다
  • options(선택 사항): metadata 및/또는 optimistic 플래그가 포함된 구성 객체입니다
  • updater: 뮤테이션할 초안을 받는 함수입니다

반환값: 뮤테이션 수명 주기를 추적하는 데 사용할 수 있는 Transaction 객체입니다.

[!IMPORTANT] updater 함수는 Immer와 유사한 패턴을 사용하여 변경 사항을 불변 업데이트로 캡처합니다. 초안 매개변수 자체를 재할당해서는 안 되며, 해당 속성만 뮤테이션해야 합니다.

삭제

컬렉션에서 항목을 제거합니다:

// Delete a single item
todoCollection.delete(todo.id)

// Delete multiple items
todoCollection.delete([todo1.id, todo2.id])

// Delete with metadata
todoCollection.delete(todo.id, {
metadata: { reason: "completed" }
})

// Delete without optimistic updates
todoCollection.delete(todo.id, { optimistic: false })

매개변수:

  • key 또는 keys: 삭제할 항목 키입니다
  • options(선택 사항): metadata 및/또는 optimistic 플래그가 포함된 구성 객체입니다

반환값: 뮤테이션 수명 주기를 추적하는 데 사용할 수 있는 Transaction 객체입니다.

작업 핸들러

작업 핸들러는 컬렉션을 만들 때 제공하는 함수로, 뮤테이션을 백엔드에 영속화합니다. 각 컬렉션은 선택적 핸들러 세 가지 onInsert, onUpdate, onDelete을 정의할 수 있습니다.

핸들러 시그니처

모든 작업 핸들러는 다음 속성을 가진 객체를 받습니다:

type OperationHandler = (params: {
transaction: Transaction
collection: Collection
}) => Promise<any> | any

transaction 객체에는 다음이 포함됩니다:

  • mutations: 각각 다음을 포함하는 뮤테이션 객체의 배열입니다:
    • collection: 뮤테이션되는 컬렉션입니다
    • type: 뮤테이션 유형('insert', 'update' 또는 'delete')입니다
    • original: 원래 항목입니다(업데이트 및 삭제의 경우)
    • modified: 수정된 항목입니다(삽입 및 업데이트의 경우)
    • changes: 변경 사항 객체입니다(업데이트의 경우)
    • key: 항목 키입니다
    • metadata: 뮤테이션에 연결된 선택적 메타데이터입니다

작업 핸들러 정의

컬렉션을 만들 때 핸들러를 정의합니다:

const todoCollection = createCollection({
id: "todos",
// ... other options

onInsert: async ({ transaction }) => {
await Promise.all(
transaction.mutations.map((mutation) =>
api.todos.create(mutation.modified)
)
)
},

onUpdate: async ({ transaction }) => {
await Promise.all(
transaction.mutations.map((mutation) =>
api.todos.update(mutation.original.id, mutation.changes)
)
)
},

onDelete: async ({ transaction }) => {
await Promise.all(
transaction.mutations.map((mutation) =>
api.todos.delete(mutation.original.id)
)
)
},
})

[!IMPORTANT] 서버 변경 사항이 컬렉션에 다시 동기화될 때까지 작업 핸들러가 완료되어서는 안 됩니다. 컬렉션 유형마다 이를 올바르게 보장하는 패턴이 다릅니다.

뮤테이션 핸들러 내부에서 collection.preload(), 라이브 쿼리 preload() 또는 직접 loadSubset()를 호출하거나 기다리면 안 됩니다. 핸들러가 시작될 때 낙관적 뮤테이션이 이미 적용되어 있습니다. 프리로드에는 동일한 핸들러 뒤에 대기 중인 동기화 커밋이 필요할 수 있으며, 이로 인해 교착 상태가 발생합니다. 대신 컬렉션 어댑터에 문서화된 뮤테이션 확인 패턴을 사용합니다.

컬렉션별 핸들러 패턴

컬렉션 유형마다 핸들러에 사용할 특정 패턴이 있습니다:

QueryCollection - 핸들러 완료 후 자동으로 다시 가져옵니다:

onUpdate: async ({ transaction }) => {
await Promise.all(
transaction.mutations.map((mutation) =>
api.todos.update(mutation.original.id, mutation.changes)
)
)
// Automatic refetch happens after handler completes
}

ElectricCollection - 동기화를 추적할 txid를 반환합니다:

onUpdate: async ({ transaction }) => {
const txids = await Promise.all(
transaction.mutations.map(async (mutation) => {
const response = await api.todos.update(mutation.original.id, mutation.changes)
return response.txid
})
)
return { txid: txids }
}

일반 뮤테이션 함수

애플리케이션 전체에서 사용할 단일 뮤테이션 함수를 정의할 수 있습니다:

import type { MutationFn } from "@tanstack/react-db"

const mutationFn: MutationFn = async ({ transaction }) => {
const response = await api.mutations.batch(transaction.mutations)

if (!response.ok) {
throw new Error(`HTTP Error: ${response.status}`)
}
}

// Use in collections
const todoCollection = createCollection({
id: "todos",
onInsert: mutationFn,
onUpdate: mutationFn,
onDelete: mutationFn,
})

뮤테이션 핸들러의 스키마 검증

컬렉션에 스키마가 구성되어 있으면 TanStack DB가 뮤테이션 중 데이터를 자동으로 검증하고 변환합니다. 뮤테이션 핸들러는 원시 입력이 아니라 변환된 데이터(TOutput)를 받습니다.

const todoSchema = z.object({
id: z.string(),
text: z.string(),
created_at: z.string().transform(val => new Date(val)) // TInput: string, TOutput: Date
})

const collection = createCollection({
schema: todoSchema,
onInsert: async ({ transaction }) => {
const item = transaction.mutations[0].modified

// item.created_at is already a Date object (TOutput)
console.log(item.created_at instanceof Date) // true

// If your API needs a string, serialize it
await api.todos.create({
...item,
created_at: item.created_at.toISOString() // Date → string
})
}
})

// User provides string (TInput)
collection.insert({
id: "1",
text: "Task",
created_at: "2024-01-01T00:00:00Z"
})

핵심 사항:

  • 스키마 검증은 뮤테이션 핸들러가 호출되기 전에 수행됩니다
  • 핸들러는 TOutput(변환된 데이터)를 받습니다
  • 백엔드에 다른 형식이 필요한 경우 핸들러에서 직렬화합니다
  • 스키마 검증 오류는 핸들러가 실행되기 전에 SchemaValidationError을 발생시킵니다

스키마 검증 및 변환에 대한 전체 문서는 스키마 가이드를 참조할 수 있습니다.

사용자 지정 액션 만들기

더 복잡한 뮤테이션 패턴에서는 createOptimisticAction를 사용하여 뮤테이션 수명 주기를 완전히 제어할 수 있는 사용자 지정 액션을 만듭니다.

기본 액션

뮤테이션 로직과 영속화를 결합하는 액션을 만듭니다:

import { createOptimisticAction } from "@tanstack/react-db"

const addTodo = createOptimisticAction<string>({
onMutate: (text) => {
// Apply optimistic state
todoCollection.insert({
id: crypto.randomUUID(),
text,
completed: false,
})
},
mutationFn: async (text, params) => {
// Persist to backend
const response = await fetch("/api/todos", {
method: "POST",
body: JSON.stringify({ text, completed: false }),
})
const result = await response.json()

// Wait for sync back
await todoCollection.utils.refetch()

return result
},
})

// Use in components
const Todo = () => {
const handleClick = () => {
addTodo("🔥 Make app faster")
}

return <Button onClick={handleClick} />
}

스키마 검증을 사용하는 타입 안전 액션

더 나은 타입 안전성과 런타임 검증을 위해 Zod, Valibot 또는 기타 스키마 검증 라이브러리를 사용할 수 있습니다. 다음은 Zod를 사용하는 예시입니다:

import { createOptimisticAction } from "@tanstack/react-db"
import { z } from "zod"

// Define a schema for the action parameters
const addTodoSchema = z.object({
text: z.string().min(1, "Todo text cannot be empty"),
priority: z.enum(["low", "medium", "high"]).optional(),
})

// Use the schema's inferred type for the generic
const addTodo = createOptimisticAction<z.infer<typeof addTodoSchema>>({
onMutate: (params) => {
// Validate parameters at runtime
const validated = addTodoSchema.parse(params)

// Apply optimistic state
todoCollection.insert({
id: crypto.randomUUID(),
text: validated.text,
priority: validated.priority ?? "medium",
completed: false,
})
},
mutationFn: async (params) => {
// Parameters are already validated
const validated = addTodoSchema.parse(params)

const response = await fetch("/api/todos", {
method: "POST",
body: JSON.stringify({
text: validated.text,
priority: validated.priority ?? "medium",
completed: false,
}),
})
const result = await response.json()

await todoCollection.utils.refetch()
return result
},
})

// Use with type-safe parameters
const Todo = () => {
const handleClick = () => {
addTodo({
text: "🔥 Make app faster",
priority: "high",
})
}

return <Button onClick={handleClick} />
}

이 패턴은 모든 검증 라이브러리(Zod, Valibot, Yup 등)에서 작동하며 다음을 제공합니다:

  • ✅ 매개변수의 런타임 검증
  • ✅ 추론된 타입을 통한 타입 안전성
  • ✅ 잘못된 입력에 대한 명확한 오류 메시지
  • ✅ 매개변수 형태에 대한 단일 진실 공급원

복잡한 다중 컬렉션 작업

작업에서 여러 컬렉션을 뮤테이션할 수 있습니다:

const createProject = createOptimisticAction<{
name: string
ownerId: string
}>({
onMutate: ({ name, ownerId }) => {
const projectId = crypto.randomUUID()

// Insert project
projectCollection.insert({
id: projectId,
name,
ownerId,
createdAt: new Date(),
})

// Update user's project count
userCollection.update(ownerId, (draft) => {
draft.projectCount += 1
})
},
mutationFn: async ({ name, ownerId }) => {
const response = await api.projects.create({ name, ownerId })

// Wait for both collections to sync
await Promise.all([
projectCollection.utils.refetch(),
userCollection.utils.refetch(),
])

return response
},
})

작업 매개변수

mutationFn은 고급 사용 사례를 위해 추가 매개변수를 받습니다:

const updateTodo = createOptimisticAction<{
id: string
changes: Partial<Todo>
}>({
onMutate: ({ id, changes }) => {
todoCollection.update(id, (draft) => {
Object.assign(draft, changes)
})
},
mutationFn: async ({ id, changes }, params) => {
// params.transaction contains the transaction object
// params.signal is an AbortSignal for cancellation

const response = await api.todos.update(id, changes, {
signal: params.signal,
})

await todoCollection.utils.refetch()
return response
},
})

수동 트랜잭션

트랜잭션 수명 주기를 최대한 제어하려면 createTransaction을 사용하여 트랜잭션을 수동으로 생성합니다. 이 접근 방식을 사용하면 여러 뮤테이션을 일괄 처리하거나, 사용자 지정 커밋 워크플로를 구현하거나, 여러 사용자 상호작용에 걸친 트랜잭션을 생성할 수 있습니다.

기본 수동 트랜잭션

import { createTransaction } from "@tanstack/react-db"

const addTodoTx = createTransaction({
autoCommit: false,
mutationFn: async ({ transaction }) => {
// Persist all mutations to backend
await Promise.all(
transaction.mutations.map((mutation) =>
api.saveTodo(mutation.modified)
)
)
},
})

// Apply first change
addTodoTx.mutate(() =>
todoCollection.insert({
id: "1",
text: "First todo",
completed: false
})
)

// User reviews change...

// Apply another change
addTodoTx.mutate(() =>
todoCollection.insert({
id: "2",
text: "Second todo",
completed: false
})
)

// User commits when ready (e.g., when they hit save)
addTodoTx.commit()

트랜잭션 구성

수동 트랜잭션은 다음 옵션을 허용합니다:

createTransaction({
id?: string, // Optional unique identifier for the transaction
autoCommit?: boolean, // Whether to automatically commit after mutate()
mutationFn: MutationFn, // Function to persist mutations
metadata?: Record<string, unknown>, // Optional custom metadata
})

autoCommit:

  • true(기본값): 각 mutate() 호출 직후 트랜잭션을 커밋합니다
  • false: 명시적인 commit() 호출을 기다립니다

트랜잭션 메서드

수동 트랜잭션은 여러 메서드를 제공합니다:

// Apply mutations within a transaction
tx.mutate(() => {
collection.insert(item)
collection.update(key, updater)
})

// Commit the transaction
await tx.commit()

// Manually rollback changes (e.g., user cancels a form)
// Note: Rollback happens automatically if mutationFn throws an error
tx.rollback()

tx.mutate() 내부에서 컬렉션 메서드를 호출하면 뮤테이션이 수동 트랜잭션에 의해 캡처됩니다. 해당 컬렉션의 onInsert, onUpdateonDelete 핸들러는 해당 뮤테이션에 대해 호출되지 않습니다. 수동 트랜잭션의 mutationFn이 영속성을 담당합니다 transaction.mutations.

따라서 수동 트랜잭션은 로컬 상태는 즉시 업데이트하되 영속성은 Save 또는 Blur와 같은 이후 사용자 작업까지 기다려야 하는 초안 스타일 워크플로에 적합합니다. 각 tx.mutate() 호출은 낙관적 상태를 즉시 업데이트하므로 사용자가 입력하는 동안 UI에 변경 사항이 반영됩니다. 사용자가 Save를 트리거하면 tx.commit()mutationFn을 실행하여 누적된 모든 뮤테이션을 단일 일괄 작업으로 영속화합니다. 대신 사용자가 취소하면 tx.rollback()이 낙관적 변경 사항을 삭제하고 UI를 되돌립니다.

다단계 워크플로

수동 트랜잭션은 복잡한 워크플로에 특히 적합합니다:

const reviewTx = createTransaction({
autoCommit: false,
mutationFn: async ({ transaction }) => {
await api.batchUpdate(transaction.mutations)
},
})

// Step 1: User makes initial changes
reviewTx.mutate(() => {
todoCollection.update(id1, (draft) => {
draft.status = "reviewed"
})
todoCollection.update(id2, (draft) => {
draft.status = "reviewed"
})
})

// Step 2: Show preview to user...

// Step 3: User confirms or makes additional changes
reviewTx.mutate(() => {
todoCollection.update(id3, (draft) => {
draft.status = "reviewed"
})
})

// Step 4: User commits all changes at once
await reviewTx.commit()
// OR user cancels
// reviewTx.rollback()

로컬 컬렉션과 함께 사용

LocalOnly 및 LocalStorage 컬렉션을 수동 트랜잭션과 함께 사용할 때는 특별한 처리가 필요합니다. onInsert, onUpdateonDelete 핸들러가 자동으로 호출되는 서버 동기화 컬렉션과 달리, 로컬 컬렉션에서는 utils.acceptMutations()을 트랜잭션의 mutationFn에서 호출하여 뮤테이션을 수동으로 수락해야 합니다.

이 작업이 필요한 이유

로컬 컬렉션(LocalOnly 및 LocalStorage)은 수동 트랜잭션에 대한 표준 뮤테이션 핸들러 흐름에 참여하지 않습니다. tx.mutate() 중에 이루어진 변경 사항을 영속화하려면 명시적으로 호출해야 합니다.

기본 사용법

import { createTransaction } from "@tanstack/react-db"
import { localOnlyCollectionOptions } from "@tanstack/react-db"

const formDraft = createCollection(
localOnlyCollectionOptions({
id: "form-draft",
getKey: (item) => item.id,
})
)

const tx = createTransaction({
autoCommit: false,
mutationFn: async ({ transaction }) => {
// Make API call with the data first
const draftData = transaction.mutations
.filter((m) => m.collection === formDraft)
.map((m) => m.modified)

await api.saveDraft(draftData)

// After API succeeds, accept and persist local collection mutations
formDraft.utils.acceptMutations(transaction)
},
})

// Apply mutations
tx.mutate(() => {
formDraft.insert({ id: "1", field: "value" })
})

// Commit when ready
await tx.commit()

로컬 컬렉션과 서버 컬렉션 결합

동일한 트랜잭션에서 로컬 컬렉션과 서버 컬렉션을 혼합할 수 있습니다:

const localSettings = createCollection(
localStorageCollectionOptions({
id: "user-settings",
storageKey: "app-settings",
getKey: (item) => item.id,
})
)

const userProfile = createCollection(
queryCollectionOptions({
queryKey: ["profile"],
queryFn: async () => api.profile.get(),
getKey: (item) => item.id,
onUpdate: async ({ transaction }) => {
await api.profile.update(transaction.mutations[0].modified)
},
})
)

const tx = createTransaction({
mutationFn: async ({ transaction }) => {
// Handle server collection mutations explicitly in mutationFn
await Promise.all(
transaction.mutations
.filter((m) => m.collection === userProfile)
.map((m) => api.profile.update(m.modified))
)

// After server mutations succeed, accept local collection mutations
localSettings.utils.acceptMutations(transaction)
},
})

// Update both local and server data in one transaction
tx.mutate(() => {
localSettings.update("theme", (draft) => {
draft.mode = "dark"
})
userProfile.update("user-1", (draft) => {
draft.name = "Updated Name"
})
})

await tx.commit()

트랜잭션 순서

트랜잭션 의미론에서 acceptMutations을 호출하는 시점이 중요합니다:

API 성공 후(일관성에 권장됨):

mutationFn: async ({ transaction }) => {
await api.save(data) // API call first
localData.utils.acceptMutations(transaction) // Persist after success
}

장점: API가 실패하면 로컬 변경 사항도 롤백됩니다(전부 성공하거나 전부 실패하는 의미론) ❌ 단점: API가 성공할 때까지 로컬 상태에 변경 사항이 반영되지 않습니다

API 호출 전(독립적인 로컬 상태를 위해):

mutationFn: async ({ transaction }) => {
localData.utils.acceptMutations(transaction) // Persist first
await api.save(data) // Then API call
}

장점: API 결과와 관계없이 로컬 상태가 즉시 영속화됩니다 ❌ 단점: API 실패 시 로컬 변경 사항이 영속화된 상태로 남습니다(상태 불일치)

로컬 데이터가 원격 뮤테이션과 독립적이어야 하는지 결합되어야 하는지에 따라 선택합니다.

모범 사례

  • 수동 트랜잭션의 로컬 컬렉션에서는 항상 utils.acceptMutations()을 호출합니다
  • 트랜잭션 일관성을 원하면 API 성공 acceptMutations을 호출합니다
  • API 호출과 관계없이 로컬 상태가 영속화되어야 하면 API 호출 acceptMutations을 호출합니다
  • 별도로 처리해야 하면 컬렉션별로 뮤테이션을 필터링합니다
  • 동일한 트랜잭션에서 로컬 컬렉션과 서버 컬렉션을 자유롭게 혼합합니다

트랜잭션 수명 주기 수신

트랜잭션 상태 변경을 모니터링합니다:

const tx = createTransaction({
autoCommit: false,
mutationFn: async ({ transaction }) => {
await api.persist(transaction.mutations)
},
})

// Wait for transaction to complete
tx.isPersisted.promise.then(() => {
console.log("Transaction persisted!")
})

// Check current state
console.log(tx.state) // 'pending', 'persisting', 'completed', or 'failed'

간격 조절 뮤테이션

간격 조절 뮤테이션은 뮤테이션을 백엔드에 언제 어떻게 영속화할지 세밀하게 제어합니다. 모든 뮤테이션을 즉시 영속화하는 대신 애플리케이션의 필요에 따라 타이밍 전략을 사용하여 뮤테이션을 일괄 처리하거나 지연하거나 대기열에 추가할 수 있습니다.

TanStack Pacer를 기반으로 하는 간격 조절 뮤테이션은 다음과 같은 시나리오에 적합합니다:

  • 사용자가 입력을 멈출 때까지 기다리는 자동 저장 양식
  • 백엔드를 과도하게 부하시키지 않고 부드러운 업데이트가 필요한 슬라이더 컨트롤
  • 순서가 중요하고 모든 뮤테이션을 영속화해야 하는 순차 워크플로

핵심 설계

전략 간 근본적인 차이는 트랜잭션을 처리하는 방식입니다:

Debounce/Throttle: 한 번에 하나의 대기 중인 트랜잭션(뮤테이션 수집)과 하나의 영속화 트랜잭션(백엔드에 기록)만 존재합니다. 빠르게 발생하는 여러 뮤테이션은 자동으로 하나의 트랜잭션으로 병합됩니다.

Queue: 각 뮤테이션이 별도의 트랜잭션을 생성하며, 생성된 순서대로 실행됩니다(기본값은 FIFO이며 LIFO로 구성할 수 있습니다). 모든 뮤테이션은 영속화가 보장됩니다.

사용 가능한 전략

전략동작적합한 용도
debounceStrategy영속화하기 전에 비활성 기간을 기다립니다. 최종 상태만 저장됩니다.자동 저장 양식, 입력 중 검색
throttleStrategy실행 간 최소 간격을 보장합니다. 실행 사이의 뮤테이션은 병합됩니다.슬라이더, 진행률 업데이트, 분석
queueStrategy각 뮤테이션이 별도의 트랜잭션이 되며 순서대로 순차 처리됩니다(기본값은 FIFO이며 LIFO로 구성할 수 있습니다). 모든 뮤테이션의 영속화가 보장됩니다.순차 워크플로, 파일 업로드, 요청 제한 API

Debounce 전략

Debounce 전략은 영속화하기 전에 일정 기간의 비활성 상태를 기다립니다. 작업을 저장하기 전에 사용자가 입력을 멈출 때까지 기다리려는 자동 저장 시나리오에 적합합니다.

import { usePacedMutations, debounceStrategy } from "@tanstack/react-db"

function AutoSaveForm({ formId }: { formId: string }) {
const mutate = usePacedMutations<{ field: string; value: string }>({
onMutate: ({ field, value }) => {
// Apply optimistic update immediately
formCollection.update(formId, (draft) => {
draft[field] = value
})
},
mutationFn: async ({ transaction }) => {
// Persist the final merged state to the backend
await api.forms.save(transaction.mutations)
},
// Wait 500ms after the last change before persisting
strategy: debounceStrategy({ wait: 500 }),
})

const handleChange = (field: string, value: string) => {
// Multiple rapid changes merge into a single transaction
mutate({ field, value })
}

return (
<form>
<input onChange={(e) => handleChange('title', e.target.value)} />
<textarea onChange={(e) => handleChange('content', e.target.value)} />
</form>
)
}

주요 특징:

  • 각 뮤테이션마다 타이머가 재설정됩니다
  • 최종적으로 병합된 상태만 영속화됩니다
  • 빠른 변경에 따른 백엔드 기록을 크게 줄입니다

Throttle 전략

Throttle 전략은 실행 간 최소 간격을 보장합니다. 백엔드에 과도한 부하를 주지 않으면서 부드럽고 일관된 업데이트를 원하는 슬라이더나 진행률 업데이트와 같은 시나리오에 적합합니다.

import { usePacedMutations, throttleStrategy } from "@tanstack/react-db"

function VolumeSlider() {
const mutate = usePacedMutations<number>({
onMutate: (volume) => {
// Apply optimistic update immediately
settingsCollection.update('volume', (draft) => {
draft.value = volume
})
},
mutationFn: async ({ transaction }) => {
await api.settings.updateVolume(transaction.mutations)
},
// Persist at most once every 200ms
strategy: throttleStrategy({
wait: 200,
leading: true, // Execute immediately on first call
trailing: true, // Execute after wait period if there were mutations
}),
})

const handleVolumeChange = (volume: number) => {
mutate(volume)
}

return (
<input
type="range"
min={0}
max={100}
onChange={(e) => handleVolumeChange(Number(e.target.value))}
/>
)
}

주요 특징:

  • 영속화 간 최소 간격을 보장합니다
  • 선행 에지, 후행 에지 또는 양쪽에서 실행할 수 있습니다
  • 실행 사이의 뮤테이션은 병합됩니다

Queue 전략

Queue 전략은 각 뮤테이션에 대해 별도의 트랜잭션을 생성하고 순서대로 처리합니다. 중간 뮤테이션을 삭제할 수 있는 debounce/throttle과 달리 모든 뮤테이션의 실행이 보장되므로 어떤 작업도 건너뛸 수 없는 워크플로에 적합합니다.

import { usePacedMutations, queueStrategy } from "@tanstack/react-db"

function FileUploader() {
const mutate = usePacedMutations<File>({
onMutate: (file) => {
// Apply optimistic update immediately
uploadCollection.insert({
id: crypto.randomUUID(),
file,
status: 'pending',
})
},
mutationFn: async ({ transaction }) => {
// Each file upload is its own transaction
const mutation = transaction.mutations[0]
await api.files.upload(mutation.modified)
},
// Process each upload sequentially with 500ms between them
strategy: queueStrategy({
wait: 500,
addItemsTo: 'back', // FIFO: add to back of queue
getItemsFrom: 'front', // FIFO: process from front of queue
}),
})

const handleFileSelect = (files: FileList) => {
// Each file creates its own transaction, queued for sequential processing
Array.from(files).forEach((file) => {
mutate(file)
})
}

return <input type="file" multiple onChange={(e) => handleFileSelect(e.target.files!)} />
}

주요 특징:

  • 각 뮤테이션이 자체 트랜잭션이 됩니다
  • 기본적으로 순서대로 순차 처리됩니다(FIFO)
  • getItemsFrom: 'back'을 설정하여 LIFO로 구성할 수 있습니다
  • 모든 뮤테이션의 실행이 보장됩니다(debounce/throttle과 달리 중간 뮤테이션이 건너뛰어질 수 있음)
  • 다음 트랜잭션을 시작하기 전에 각 트랜잭션이 완료되기를 기다립니다

오류 처리:

  • 뮤테이션이 실패해도 자동으로 재시도되지 않습니다 - 트랜잭션이 "failed" 상태로 전환됩니다
  • 실패한 뮤테이션은 transaction.isPersisted.promise을 통해 오류를 노출하며(해당 호출은 reject됩니다)
  • 후속 뮤테이션은 계속 처리됩니다 - 한 번의 실패가 대기열을 차단하지 않습니다
  • 각 뮤테이션은 독립적이며 여러 뮤테이션에 걸쳐 전부 성공하거나 전부 실패하는 트랜잭션 의미론은 없습니다
  • 재시도 로직을 구현하려면 Retry Behavior를 참조합니다

전략 선택

사용 사례에 적합한 전략을 선택하려면 이 가이드를 사용합니다:

debounceStrategy 사용 시:

  • 사용자가 작업을 끝낼 때까지 기다리려는 경우
  • 최종 상태만 중요하고 중간 상태는 삭제해도 되는 경우
  • 백엔드 기록을 최소화하려는 경우
  • 예: 자동 저장 양식, 입력 중 검색, 설정 패널

throttleStrategy 사용 시:

  • 제어된 속도로 부드럽고 일관된 업데이트를 원하는 경우
  • 일부 중간 상태는 영속화해야 하지만 전부는 아닌 경우
  • 백엔드에 과도한 부하를 주지 않으면서 업데이트가 반응성 있게 느껴져야 하는 경우
  • 예: 볼륨 슬라이더, 진행률 표시줄, 분석 추적, 라이브 커서 위치

queueStrategy 사용 시:

  • 모든 뮤테이션을 영속화해야 하는 경우(어떤 작업도 손실될 수 없음)
  • 작업 순서가 중요한 경우
  • 요청 제한 API를 사용하는 경우
  • 지연을 포함한 순차 처리가 필요한 경우
  • 예: 파일 업로드, 일괄 작업, 감사 추적, 다단계 마법사

React에서 사용

usePacedMutations 훅을 사용하면 React 컴포넌트에서 간격 조절 뮤테이션을 쉽게 사용할 수 있습니다:

import { usePacedMutations, debounceStrategy } from "@tanstack/react-db"

function MyComponent({ itemId }: { itemId: string }) {
const mutate = usePacedMutations<number>({
onMutate: (newValue) => {
// Apply optimistic update immediately
collection.update(itemId, (draft) => {
draft.value = newValue
})
},
mutationFn: async ({ transaction }) => {
await api.save(transaction.mutations)
},
strategy: debounceStrategy({ wait: 500 }),
})

// Each mutate call returns a Transaction you can await
const handleSave = async (newValue: number) => {
const tx = mutate(newValue)

// Optionally wait for persistence
try {
await tx.isPersisted.promise
console.log('Saved successfully!')
} catch (error) {
console.error('Save failed:', error)
}
}

return <button onClick={() => handleSave(42)}>Save</button>
}

이 훅은 불필요한 재생성을 방지하기 위해 전략과 뮤테이션 함수를 자동으로 메모이제이션합니다. React 외부에서는 createPacedMutations을 직접 사용할 수도 있습니다:

import { createPacedMutations, queueStrategy } from "@tanstack/db"

const mutate = createPacedMutations<{ id: string; changes: Partial<Item> }>({
onMutate: ({ id, changes }) => {
// Apply optimistic update immediately
collection.update(id, (draft) => {
Object.assign(draft, changes)
})
},
mutationFn: async ({ transaction }) => {
await api.save(transaction.mutations)
},
strategy: queueStrategy({ wait: 200 }),
})

// Use anywhere in your application
mutate({ id: '123', changes: { name: 'New Name' } })

대기열 및 훅 인스턴스 이해

각각의 고유한 usePacedMutations 훅 호출은 독립적인 대기열을 생성합니다. 이는 뮤테이션 구조에 영향을 주는 중요한 설계 결정입니다.

여러 컴포넌트에서 usePacedMutations을 별도로 호출하면 각 컴포넌트에 격리된 대기열이 생성됩니다:

function EmailDraftEditor1({ draftId }: { draftId: string }) {
// This creates Queue A
const mutate = usePacedMutations({
onMutate: (text) => {
draftCollection.update(draftId, (draft) => {
draft.text = text
})
},
mutationFn: async ({ transaction }) => {
await api.saveDraft(transaction.mutations)
},
strategy: debounceStrategy({ wait: 500 }),
})

return <textarea onChange={(e) => mutate(e.target.value)} />
}

function EmailDraftEditor2({ draftId }: { draftId: string }) {
// This creates Queue B (separate from Queue A)
const mutate = usePacedMutations({
onMutate: (text) => {
draftCollection.update(draftId, (draft) => {
draft.text = text
})
},
mutationFn: async ({ transaction }) => {
await api.saveDraft(transaction.mutations)
},
strategy: debounceStrategy({ wait: 500 }),
})

return <textarea onChange={(e) => mutate(e.target.value)} />
}

이 예시에서 EmailDraftEditor1EmailDraftEditor2의 뮤테이션은 독립적으로 대기열에 추가되고 처리됩니다. 동일한 debounce 타이머나 대기열을 공유하지 않습니다.

여러 컴포넌트에서 동일한 대기열을 공유하려면, 하나의 createPacedMutations 인스턴스를 생성하고 모든 곳에서 사용합니다:

// Create a single shared instance
import { createPacedMutations, debounceStrategy } from "@tanstack/db"

export const mutateDraft = createPacedMutations<{ draftId: string; text: string }>({
onMutate: ({ draftId, text }) => {
draftCollection.update(draftId, (draft) => {
draft.text = text
})
},
mutationFn: async ({ transaction }) => {
await api.saveDraft(transaction.mutations)
},
strategy: debounceStrategy({ wait: 500 }),
})

// Now both components share the same queue
function EmailDraftEditor1({ draftId }: { draftId: string }) {
return <textarea onChange={(e) => mutateDraft({ draftId, text: e.target.value })} />
}

function EmailDraftEditor2({ draftId }: { draftId: string }) {
return <textarea onChange={(e) => mutateDraft({ draftId, text: e.target.value })} />
}

이 접근 방식을 사용하면 두 컴포넌트의 모든 뮤테이션이 동일한 debounce 타이머와 대기열을 공유하므로 하나의 debounce 구현으로 올바른 순서에 따라 처리됩니다.

핵심 요점:

  • usePacedMutations() 호출 = 고유한 대기열
  • createPacedMutations() 호출 = 고유한 대기열
  • 대기열을 공유하려면 인스턴스 하나를 생성하고 필요한 모든 곳에서 가져옵니다
  • 공유 대기열은 서로 다른 위치의 뮤테이션이 올바른 순서로 정렬되도록 합니다

뮤테이션 병합

트랜잭션 내에서 여러 뮤테이션이 동일한 항목에 대해 동작하면 TanStack DB는 다음을 위해 이를 지능적으로 병합합니다:

  • 네트워크 트래픽 감소: 서버로 전송되는 뮤테이션 수 감소
  • 사용자 의도 보존: 최종 상태가 사용자가 예상한 것과 일치
  • UI 일관성 유지: 로컬 상태가 항상 사용자 작업을 반영

병합 동작은 뮤테이션 유형에 따른 진리표를 따릅니다:

기존 → 신규결과설명
insert + updateinsertinsert 유형을 유지하고 변경 사항을 병합하며 원본을 비움
insert + deleteremoved뮤테이션이 서로 상쇄됨
update + deletedeletedelete가 우선함
update + updateupdate변경 사항을 합집합으로 만들고 첫 번째 원본을 유지

[!NOTE] 트랜잭션 내에서 동일한 항목을 여러 번 삽입하거나 삭제하려고 하면 오류가 발생합니다.

낙관적 동작 제어

기본적으로 모든 뮤테이션은 즉각적인 피드백을 제공하기 위해 낙관적 업데이트를 즉시 적용합니다. 그러나 로컬에 변경 사항을 적용하기 전에 서버 확인을 기다려야 하는 경우 이 동작을 비활성화할 수 있습니다.

낙관적 업데이트를 비활성화할 시점

다음과 같은 경우 optimistic: false 사용을 고려합니다:

  • 복잡한 서버 측 처리: 서버 측 생성에 의존하는 작업(예: 외래 키 연쇄, 계산 필드)
  • 검증 요구 사항: 백엔드 검증에서 변경을 거부할 수 있는 작업
  • 확인 워크플로: 데이터를 제거하기 전에 UX에서 확인을 기다려야 하는 삭제 작업
  • 일괄 작업: 낙관적 롤백이 방해가 될 수 있는 대규모 작업

동작 차이

optimistic: true (기본값):

  • 로컬 저장소에 뮤테이션을 즉시 적용합니다
  • UI에 즉각적인 피드백을 제공합니다
  • 서버에서 뮤테이션을 거부하면 롤백이 필요합니다
  • 단순하고 예측 가능한 작업에 가장 적합합니다

optimistic: false:

  • 서버가 확인할 때까지 로컬 저장소를 수정하지 않습니다
  • 즉각적인 UI 피드백은 없지만 롤백이 필요하지 않습니다
  • 서버 응답이 성공한 후에만 UI가 업데이트됩니다
  • 복잡하거나 검증이 많은 작업에 가장 적합합니다

비낙관적 뮤테이션 사용

// Critical deletion that needs confirmation
const handleDeleteAccount = () => {
userCollection.delete(userId, { optimistic: false })
}

// Server-generated data
const handleCreateInvoice = () => {
// Server generates invoice number, tax calculations, etc.
invoiceCollection.insert(invoiceData, { optimistic: false })
}

// Mixed approach in same transaction
tx.mutate(() => {
// Instant UI feedback for simple change
todoCollection.update(todoId, (draft) => {
draft.completed = true
})

// Wait for server confirmation for complex change
auditCollection.insert(auditRecord, { optimistic: false })
})

영속성 대기

optimistic: false에서 흔히 사용하는 패턴은 탐색하거나 성공 피드백을 표시하기 전에 뮤테이션이 완료될 때까지 기다리는 것입니다:

const handleCreatePost = async (postData) => {
// Insert without optimistic updates
const tx = postsCollection.insert(postData, { optimistic: false })

try {
// Wait for write to server and sync back to complete
await tx.isPersisted.promise

// Server write and sync back were successful
navigate(`/posts/${postData.id}`)
} catch (error) {
// Show error notification
toast.error("Failed to create post: " + error.message)
}
}

// Works with updates and deletes too
const handleUpdateTodo = async (todoId, changes) => {
const tx = todoCollection.update(
todoId,
{ optimistic: false },
(draft) => Object.assign(draft, changes)
)

try {
await tx.isPersisted.promise
navigate("/todos")
} catch (error) {
toast.error("Failed to update todo: " + error.message)
}
}

트랜잭션 상태

트랜잭션은 수명 주기 동안 다음 상태를 거칩니다:

  1. pending: 트랜잭션이 생성되고 낙관적 뮤테이션을 적용할 수 있는 초기 상태입니다
  2. persisting: 트랜잭션이 백엔드에 영속화되는 중입니다
  3. completed: 트랜잭션이 성공적으로 영속화되었으며 백엔드 변경 사항이 다시 동기화되었습니다
  4. failed: 트랜잭션을 영속화하거나 다시 동기화하는 중 오류가 발생했습니다

트랜잭션 상태 모니터링

const tx = todoCollection.update(todoId, (draft) => {
draft.completed = true
})

// Check current state
console.log(tx.state) // 'pending'

// Wait for specific states
await tx.isPersisted.promise
console.log(tx.state) // 'completed' or 'failed'

// Handle errors
try {
await tx.isPersisted.promise
console.log("Success!")
} catch (error) {
console.log("Failed:", error)
}

상태 전환

정상적인 흐름은 다음과 같습니다: pendingpersistingcompleted

오류가 발생하면 다음과 같습니다: pendingpersistingfailed

실패한 트랜잭션은 낙관적 상태를 자동으로 롤백합니다.

재시도 동작

중요: TanStack DB는 실패한 뮤테이션을 자동으로 재시도하지 않습니다. 뮤테이션이 실패하면(네트워크 오류, 서버 오류 등) 트랜잭션은 failed 상태로 전환되고 낙관적 상태가 롤백됩니다. 이는 의도된 동작입니다. 자동 재시도 로직은 사용 사례에 따라 크게 달라집니다(멱등성 요구 사항, 오류 유형, 백오프 전략 등).

재시도 로직을 구현하려면 API 호출을 다음 mutationFn으로 감쌉니다:

// Simple retry helper
async function withRetry<T>(
fn: () => Promise<T>,
maxRetries = 3,
delay = 1000
): Promise<T> {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await fn()
} catch (error) {
if (attempt === maxRetries - 1) throw error
await new Promise(resolve => setTimeout(resolve, delay * (attempt + 1)))
}
}
throw new Error('Unreachable')
}

// Use in your collection
const todoCollection = createCollection({
id: "todos",
onUpdate: async ({ transaction }) => {
const mutation = transaction.mutations[0]
// Retry up to 3 times with increasing delay
await withRetry(() =>
api.todos.update(mutation.original.id, mutation.changes)
)
},
})

더 정교한 재시도 전략에는 지수 백오프, 사용자 지정 재시도 조건, 중단 신호를 지원하는 p-retry와 같은 라이브러리 사용을 고려할 수 있습니다.

임시 ID 처리

서버가 최종 ID를 생성하는 컬렉션에 새 항목을 삽입할 때는 UI 문제와 작업 실패를 방지하기 위해 임시 ID에서 실제 ID로의 전환을 신중하게 처리해야 합니다.

문제

임시 ID로 항목을 삽입하면 낙관적 객체가 결국 실제 서버 생성 ID를 가진 동기화된 객체로 교체됩니다. 이로 인해 두 가지 문제가 발생할 수 있습니다:

  1. UI 깜박임: 키가 임시 ID에서 실제 ID로 변경될 때 UI 프레임워크가 컴포넌트를 언마운트한 후 다시 마운트할 수 있습니다
  2. 후속 작업: 실제 ID가 다시 동기화되기 전에 임시 ID를 사용하려고 하면 삭제와 같은 작업이 실패할 수 있습니다
// Generate temporary ID (e.g., negative number)
const tempId = -(Math.floor(Math.random() * 1000000) + 1)

// Insert with temporary ID
todoCollection.insert({
id: tempId,
text: "New todo",
completed: false
})

// Problem 1: UI may re-render when tempId is replaced with real ID
// Problem 2: Trying to delete before sync completes will use tempId
todoCollection.delete(tempId) // May 404 on backend

해결 방법 1: 클라이언트 생성 UUID 사용

백엔드가 클라이언트 생성 ID를 지원한다면 UUID를 사용하여 임시 ID 문제를 완전히 제거해야 합니다:

// Generate UUID on client
const id = crypto.randomUUID()

todoCollection.insert({
id,
text: "New todo",
completed: false
})

// No flicker - the ID is stable
// Subsequent operations work immediately
todoCollection.delete(id) // Works with the same ID

백엔드가 이를 지원할 때 가장 깔끔한 방법이며, ID가 변경되지 않습니다.

해결 방법 2: 영속성을 기다리거나 비낙관적 삽입 사용

후속 작업을 허용하기 전에 뮤테이션이 영속화될 때까지 기다리거나, 실제 ID를 사용할 수 있을 때까지 항목을 표시하지 않도록 비낙관적 삽입을 사용합니다:

const handleCreateTodo = async (text: string) => {
const tempId = -Math.floor(Math.random() * 1000000) + 1

const tx = todoCollection.insert({
id: tempId,
text,
completed: false
})

// Wait for persistence to complete
await tx.isPersisted.promise

// Now we have the real ID from the server
// Subsequent operations will use the real ID
}

// Disable delete buttons until persisted
const TodoItem = ({ todo, isPersisted }: { todo: Todo, isPersisted: boolean }) => {
return (
<div>
{todo.text}
<button
onClick={() => todoCollection.delete(todo.id)}
disabled={!isPersisted}
>
Delete
</button>
</div>
)
}

해결 방법 3: 뷰 키 매핑 유지

낙관적 업데이트를 유지하면서 UI 깜박임을 방지하려면 ID(임시 및 실제)를 안정적인 뷰 키에 매핑하는 별도의 매핑을 유지합니다:

// Create a mapping API
const idToViewKey = new Map<number | string, string>()

function getViewKey(id: number | string): string {
if (!idToViewKey.has(id)) {
idToViewKey.set(id, crypto.randomUUID())
}
return idToViewKey.get(id)!
}

function linkIds(tempId: number, realId: number) {
const viewKey = getViewKey(tempId)
idToViewKey.set(realId, viewKey)
}

// Configure collection to link IDs when real ID comes back
const todoCollection = createCollection({
id: "todos",
// ... other options
onInsert: async ({ transaction }) => {
const mutation = transaction.mutations[0]
const tempId = mutation.modified.id

// Create todo on server and get real ID back
const response = await api.todos.create({
text: mutation.modified.text,
completed: mutation.modified.completed,
})
const realId = response.id

// Link temp ID to same view key as real ID
linkIds(tempId, realId)

// Wait for sync back
await todoCollection.utils.refetch()
},
})

// When inserting with temp ID
const tempId = -Math.floor(Math.random() * 1000000) + 1
const viewKey = getViewKey(tempId) // Creates and stores mapping

todoCollection.insert({
id: tempId,
text: "New todo",
completed: false
})

// Use view key for rendering
const TodoList = () => {
const { data: todos } = useLiveQuery({
query: (q) => q.from({ todo: todoCollection }),
})

return (
<ul>
{todos.map((todo) => (
<li key={getViewKey(todo.id)}> {/* Stable key */}
{todo.text}
</li>
))}
</ul>
)
}

이 패턴은 임시 ID에서 실제 ID로 전환되는 동안 안정적인 키를 유지하여 UI 프레임워크가 컴포넌트를 언마운트하고 다시 마운트하는 것을 방지합니다. 뷰 키는 컬렉션 항목 외부에 저장되므로 데이터 모델에 추가 필드를 넣을 필요가 없습니다.

모범 사례

  1. 가능하면 UUID 사용: 클라이언트 생성 UUID는 임시 ID 문제를 제거합니다
  2. 임시 ID를 결정적으로 생성: 음수나 특정 패턴을 사용하여 임시 ID와 실제 ID를 구분합니다
  3. 임시 항목에 대한 작업 비활성화: 영속성이 완료될 때까지 삭제/업데이트 버튼을 비활성화합니다
  4. 뷰 키 매핑 유지: 렌더링을 위해 ID와 안정적인 뷰 키 간의 매핑을 생성합니다

[!NOTE] TanStack DB에서 임시 ID 처 built-in 지원을 개선하기 위한 open issue가 있습니다. 이를 통해 뷰 키 패턴이 자동화되고 서버 생성 ID를 더 쉽게 사용할 수 있게 됩니다.