Skip to content

Commit 0ccb185

Browse files
localOnlyCollection for ephemeral local state (#204)
Co-authored-by: Cursor Agent <[email protected]>
1 parent 6e57ac3 commit 0ccb185

File tree

6 files changed

+862
-3
lines changed

6 files changed

+862
-3
lines changed

.changeset/local-only-collection.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@tanstack/db-collections": patch
3+
---
4+
5+
Add localOnly collection type for in-memory collections with loopback sync.

docs/overview.md

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ There are a number of built-in collection types implemented in [`@tanstack/db-co
154154
1. [`QueryCollection`](#querycollection) to load data into collections using [TanStack Query](https://tanstack.com/query)
155155
2. [`ElectricCollection`](#electriccollection) to sync data into collections using [ElectricSQL](https://electric-sql.com)
156156
3. [`LocalStorageCollection`](#localstoragecollection) for small amounts of local-only state that syncs across browser tabs
157-
4. [WIP] [`LocalOnlyCollection`](#localonlycollection) for in-memory client data or UI state
157+
4. [`LocalOnlyCollection`](#localonlycollection) for in-memory client data or UI state
158158

159159
You can also use:
160160

@@ -287,9 +287,55 @@ export const sessionCollection = createCollection(localStorageCollectionOptions(
287287
288288
#### `LocalOnlyCollection`
289289

290-
This is WIP. Track progress at [#79](https://github.com/TanStack/db/issues/79).
290+
LocalOnly collections are designed for in-memory client data or UI state that doesn't need to persist across browser sessions or sync across tabs. They provide a simple way to manage temporary, session-only data with full optimistic mutation support.
291291

292-
LocalOnly collections will be designed for in-memory client data or UI state that doesn't need to persist across browser sessions or sync across tabs.
292+
Use `localOnlyCollectionOptions` to create a collection that stores data only in memory:
293+
294+
```ts
295+
import { createCollection } from '@tanstack/react-db'
296+
import { localOnlyCollectionOptions } from '@tanstack/db-collections'
297+
298+
export const uiStateCollection = createCollection(localOnlyCollectionOptions({
299+
id: 'ui-state',
300+
getKey: (item) => item.id,
301+
schema: uiStateSchema,
302+
// Optional initial data to populate the collection
303+
initialData: [
304+
{ id: 'sidebar', isOpen: false },
305+
{ id: 'theme', mode: 'light' }
306+
]
307+
}))
308+
```
309+
310+
The LocalOnly collection requires:
311+
312+
- `getKey` — identifies the id for items in the collection
313+
314+
Optional configuration:
315+
316+
- `initialData` — array of items to populate the collection with on creation
317+
- `onInsert`, `onUpdate`, `onDelete` — optional mutation handlers for custom logic
318+
319+
Mutation handlers are completely optional. When provided, they are called before the optimistic state is confirmed. The collection automatically manages the transition from optimistic to confirmed state internally.
320+
321+
```ts
322+
export const tempDataCollection = createCollection(localOnlyCollectionOptions({
323+
id: 'temp-data',
324+
getKey: (item) => item.id,
325+
onInsert: async ({ transaction }) => {
326+
// Custom logic before confirming the insert
327+
console.log('Inserting:', transaction.mutations[0].modified)
328+
},
329+
onUpdate: async ({ transaction }) => {
330+
// Custom logic before confirming the update
331+
const { original, modified } = transaction.mutations[0]
332+
console.log('Updating from', original, 'to', modified)
333+
}
334+
}))
335+
```
336+
337+
> [!TIP]
338+
> LocalOnly collections are perfect for temporary UI state, form data, or any client-side data that doesn't need persistence. For data that should persist across sessions, use [`LocalStorageCollection`](#localstoragecollection) instead.
293339
294340
#### Derived collections
295341

packages/db-collections/src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,8 @@ export {
1515
type StorageApi,
1616
type StorageEventApi,
1717
} from "./local-storage"
18+
export {
19+
localOnlyCollectionOptions,
20+
type LocalOnlyCollectionConfig,
21+
type LocalOnlyCollectionUtils,
22+
} from "./local-only"
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
import type {
2+
DeleteMutationFnParams,
3+
InsertMutationFnParams,
4+
OperationType,
5+
ResolveType,
6+
SyncConfig,
7+
UpdateMutationFnParams,
8+
UtilsRecord,
9+
} from "@tanstack/db"
10+
import type { StandardSchemaV1 } from "@standard-schema/spec"
11+
12+
/**
13+
* Configuration interface for Local-only collection options
14+
* @template TExplicit - The explicit type of items in the collection (highest priority)
15+
* @template TSchema - The schema type for validation and type inference (second priority)
16+
* @template TFallback - The fallback type if no explicit or schema type is provided
17+
* @template TKey - The type of the key returned by getKey
18+
*
19+
* @remarks
20+
* Type resolution follows a priority order:
21+
* 1. If you provide an explicit type via generic parameter, it will be used
22+
* 2. If no explicit type is provided but a schema is, the schema's output type will be inferred
23+
* 3. If neither explicit type nor schema is provided, the fallback type will be used
24+
*
25+
* You should provide EITHER an explicit type OR a schema, but not both, as they would conflict.
26+
*/
27+
export interface LocalOnlyCollectionConfig<
28+
TExplicit = unknown,
29+
TSchema extends StandardSchemaV1 = never,
30+
TFallback extends Record<string, unknown> = Record<string, unknown>,
31+
TKey extends string | number = string | number,
32+
> {
33+
/**
34+
* Standard Collection configuration properties
35+
*/
36+
id?: string
37+
schema?: TSchema
38+
getKey: (item: ResolveType<TExplicit, TSchema, TFallback>) => TKey
39+
40+
/**
41+
* Optional initial data to populate the collection with on creation
42+
* This data will be applied during the initial sync process
43+
*/
44+
initialData?: Array<ResolveType<TExplicit, TSchema, TFallback>>
45+
46+
/**
47+
* Optional asynchronous handler function called after an insert operation
48+
* @param params Object containing transaction and mutation information
49+
* @returns Promise resolving to any value
50+
*/
51+
onInsert?: (
52+
params: InsertMutationFnParams<ResolveType<TExplicit, TSchema, TFallback>>
53+
) => Promise<any>
54+
55+
/**
56+
* Optional asynchronous handler function called after an update operation
57+
* @param params Object containing transaction and mutation information
58+
* @returns Promise resolving to any value
59+
*/
60+
onUpdate?: (
61+
params: UpdateMutationFnParams<ResolveType<TExplicit, TSchema, TFallback>>
62+
) => Promise<any>
63+
64+
/**
65+
* Optional asynchronous handler function called after a delete operation
66+
* @param params Object containing transaction and mutation information
67+
* @returns Promise resolving to any value
68+
*/
69+
onDelete?: (
70+
params: DeleteMutationFnParams<ResolveType<TExplicit, TSchema, TFallback>>
71+
) => Promise<any>
72+
}
73+
74+
/**
75+
* Local-only collection utilities type (currently empty but matches the pattern)
76+
*/
77+
export interface LocalOnlyCollectionUtils extends UtilsRecord {}
78+
79+
/**
80+
* Creates Local-only collection options for use with a standard Collection
81+
* This is an in-memory collection that doesn't sync but uses a loopback sync config
82+
* that immediately "syncs" all optimistic changes to the collection.
83+
*
84+
* @template TExplicit - The explicit type of items in the collection (highest priority)
85+
* @template TSchema - The schema type for validation and type inference (second priority)
86+
* @template TFallback - The fallback type if no explicit or schema type is provided
87+
* @template TKey - The type of the key returned by getKey
88+
* @param config - Configuration options for the Local-only collection
89+
* @returns Collection options with utilities
90+
*/
91+
export function localOnlyCollectionOptions<
92+
TExplicit = unknown,
93+
TSchema extends StandardSchemaV1 = never,
94+
TFallback extends Record<string, unknown> = Record<string, unknown>,
95+
TKey extends string | number = string | number,
96+
>(config: LocalOnlyCollectionConfig<TExplicit, TSchema, TFallback, TKey>) {
97+
type ResolvedType = ResolveType<TExplicit, TSchema, TFallback>
98+
99+
const { initialData, onInsert, onUpdate, onDelete, ...restConfig } = config
100+
101+
// Create the sync configuration with transaction confirmation capability
102+
const syncResult = createLocalOnlySync<ResolvedType, TKey>(initialData)
103+
104+
// Create wrapper handlers that call user handlers first, then confirm transactions
105+
const wrappedOnInsert = async (
106+
params: InsertMutationFnParams<ResolvedType>
107+
) => {
108+
// Call user handler first if provided
109+
let handlerResult
110+
if (onInsert) {
111+
handlerResult = (await onInsert(params)) ?? {}
112+
}
113+
114+
// Then synchronously confirm the transaction by looping through mutations
115+
syncResult.confirmOperationsSync(params.transaction.mutations)
116+
117+
return handlerResult
118+
}
119+
120+
const wrappedOnUpdate = async (
121+
params: UpdateMutationFnParams<ResolvedType>
122+
) => {
123+
// Call user handler first if provided
124+
let handlerResult
125+
if (onUpdate) {
126+
handlerResult = (await onUpdate(params)) ?? {}
127+
}
128+
129+
// Then synchronously confirm the transaction by looping through mutations
130+
syncResult.confirmOperationsSync(params.transaction.mutations)
131+
132+
return handlerResult
133+
}
134+
135+
const wrappedOnDelete = async (
136+
params: DeleteMutationFnParams<ResolvedType>
137+
) => {
138+
// Call user handler first if provided
139+
let handlerResult
140+
if (onDelete) {
141+
handlerResult = (await onDelete(params)) ?? {}
142+
}
143+
144+
// Then synchronously confirm the transaction by looping through mutations
145+
syncResult.confirmOperationsSync(params.transaction.mutations)
146+
147+
return handlerResult
148+
}
149+
150+
return {
151+
...restConfig,
152+
sync: syncResult.sync,
153+
onInsert: wrappedOnInsert,
154+
onUpdate: wrappedOnUpdate,
155+
onDelete: wrappedOnDelete,
156+
utils: {} as LocalOnlyCollectionUtils,
157+
startSync: true,
158+
gcTime: 0,
159+
}
160+
}
161+
162+
/**
163+
* Internal function to create Local-only sync configuration with transaction confirmation
164+
* This captures the sync functions and provides synchronous confirmation of operations
165+
*/
166+
function createLocalOnlySync<T extends object, TKey extends string | number>(
167+
initialData?: Array<T>
168+
) {
169+
// Capture sync functions for transaction confirmation
170+
let syncBegin: (() => void) | null = null
171+
let syncWrite: ((message: { type: OperationType; value: T }) => void) | null =
172+
null
173+
let syncCommit: (() => void) | null = null
174+
175+
const sync: SyncConfig<T, TKey> = {
176+
sync: (params) => {
177+
const { begin, write, commit } = params
178+
179+
// Capture sync functions for later use by confirmOperationsSync
180+
syncBegin = begin
181+
syncWrite = write
182+
syncCommit = commit
183+
184+
// Apply initial data if provided
185+
if (initialData && initialData.length > 0) {
186+
begin()
187+
initialData.forEach((item) => {
188+
write({
189+
type: `insert`,
190+
value: item,
191+
})
192+
})
193+
commit()
194+
}
195+
196+
// Return empty unsubscribe function - no ongoing sync needed
197+
return () => {}
198+
},
199+
getSyncMetadata: () => ({}),
200+
}
201+
202+
/**
203+
* Synchronously confirms optimistic operations by immediately writing through sync
204+
* This loops through transaction mutations and applies them to move from optimistic to synced state
205+
*/
206+
const confirmOperationsSync = (mutations: Array<any>) => {
207+
if (!syncBegin || !syncWrite || !syncCommit) {
208+
return // Sync not initialized yet, which is fine
209+
}
210+
211+
// Immediately write back through sync interface
212+
syncBegin()
213+
mutations.forEach((mutation) => {
214+
if (syncWrite) {
215+
syncWrite({
216+
type: mutation.type,
217+
value: mutation.modified,
218+
})
219+
}
220+
})
221+
syncCommit()
222+
}
223+
224+
return {
225+
sync,
226+
confirmOperationsSync,
227+
}
228+
}

0 commit comments

Comments
 (0)