Skip to content

Commit 4f376c7

Browse files
committed
feat: Add POC for store pic (concurrent stores) pattern in React hooks
Implements a proof-of-concept demonstrating React's upcoming "concurrent stores" pattern (also called "store pic") for useLiveQuery. This enables concurrent-safe behavior with React transitions and prevents UI tearing. Background: - React's useSyncExternalStore forces synchronous updates, breaking concurrent features - React is introducing a concurrent stores API to enable external stores to work properly with transitions, Suspense, and concurrent rendering - This POC adapts the pattern from react-concurrent-store and react-redux PR #2263 Key Components: - CollectionStore: Wraps TanStack Collections with committed/pending snapshots - useLiveQueryConcurrent: Alternative to useLiveQuery using the store pic pattern - CollectionStoreProvider: Context provider for managing store commits - StoreManager: Tracks store commits across the React tree with reference counting Features: - Maintains dual snapshots (committed vs pending) for concurrent safety - Implements state rebasing when sync updates occur during transitions - Prevents tearing by ensuring components mounting mid-transition see consistent state - Clever mounting strategy that entangles with ongoing transitions - Reference-counted store management for proper cleanup Documentation: - README.md: Overview and usage guide - COMPARISON.md: Detailed comparison with current useLiveQuery - TECHNICAL.md: Deep dive into implementation internals - example.tsx: Comprehensive usage examples Benefits: - Works properly with React transitions (non-blocking, interruptible) - Prevents UI tearing when components mount during transitions - Aligns with upcoming React concurrent stores API - Better UX through non-blocking updates Trade-offs: - Requires CollectionStoreProvider wrapper - Slightly higher memory usage (dual snapshots) - Small performance overhead for commit tracking - Uses React internals (experimental, subject to change) References: - reduxjs/react-redux#2263 - https://github.com/thejustinwalsh/react-concurrent-store - https://react.dev/blog/2025/04/23/react-labs-view-transitions-activity-and-more#concurrent-stores
1 parent 48b8e8f commit 4f376c7

File tree

9 files changed

+1948
-0
lines changed

9 files changed

+1948
-0
lines changed
Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
# Comparison: useLiveQuery vs useLiveQueryConcurrent
2+
3+
This document compares the current `useLiveQuery` implementation with the POC `useLiveQueryConcurrent` implementation.
4+
5+
## Architecture Differences
6+
7+
### Current: useLiveQuery
8+
9+
```
10+
Component
11+
12+
useLiveQuery
13+
14+
useSyncExternalStore ← Forces synchronous updates
15+
16+
Collection.subscribeChanges()
17+
```
18+
19+
**Key characteristics:**
20+
- Uses React's `useSyncExternalStore` hook
21+
- All updates are synchronous (bypasses concurrent features)
22+
- Simple, well-tested, performant
23+
- No provider required
24+
25+
### POC: useLiveQueryConcurrent
26+
27+
```
28+
Component
29+
30+
useLiveQueryConcurrent
31+
32+
useCollectionStore (custom hook)
33+
34+
CollectionStore (wraps Collection)
35+
├── committedSnapshot ← Shown to sync renders
36+
└── pendingSnapshot ← Shown to transition renders
37+
38+
StoreManager (via CollectionStoreProvider)
39+
└── Tracks commits across React tree
40+
```
41+
42+
**Key characteristics:**
43+
- Uses custom `useCollectionStore` hook implementing store pic pattern
44+
- Maintains committed vs pending snapshots
45+
- Concurrent-safe (works with transitions)
46+
- Requires `CollectionStoreProvider` wrapper
47+
48+
## Code Comparison
49+
50+
### Subscription Pattern
51+
52+
**useLiveQuery:**
53+
```typescript
54+
const snapshot = useSyncExternalStore(
55+
subscribeRef.current, // Subscribe to changes
56+
getSnapshotRef.current // Get current snapshot
57+
)
58+
```
59+
60+
**useLiveQueryConcurrent:**
61+
```typescript
62+
const store = new CollectionStore(collection)
63+
const snapshot = useCollectionStore(store)
64+
// Internally handles committed vs pending state
65+
```
66+
67+
### State Management
68+
69+
**useLiveQuery:**
70+
```typescript
71+
// Single snapshot with versioning
72+
const snapshot = {
73+
collection: collectionRef.current,
74+
version: versionRef.current
75+
}
76+
```
77+
78+
**useLiveQueryConcurrent:**
79+
```typescript
80+
// Dual snapshots for concurrent safety
81+
const committedSnapshot = {
82+
entries: [...],
83+
status: 'ready',
84+
version: 1
85+
}
86+
const pendingSnapshot = {
87+
entries: [...],
88+
status: 'ready',
89+
version: 2 // May differ during transition
90+
}
91+
```
92+
93+
### Component Mounting
94+
95+
**useLiveQuery:**
96+
- New components always see current state
97+
- Can cause tearing if mounting during transition
98+
99+
**useLiveQueryConcurrent:**
100+
- Mounts with pending state initially
101+
- Fixes up to committed state in useLayoutEffect
102+
- Prevents tearing by synchronizing with transition
103+
104+
## When Updates Happen
105+
106+
### Scenario 1: Normal Update (No Transition)
107+
108+
**Both implementations:**
109+
1. Collection changes
110+
2. Components re-render with new data
111+
3. Works identically
112+
113+
### Scenario 2: Update During Transition
114+
115+
**useLiveQuery:**
116+
```typescript
117+
startTransition(() => {
118+
setFilter('high-priority')
119+
})
120+
// ❌ Update forces synchronous render
121+
// ❌ Bypasses transition
122+
// ❌ Can cause jank
123+
```
124+
125+
**useLiveQueryConcurrent:**
126+
```typescript
127+
startTransition(() => {
128+
setFilter('high-priority')
129+
})
130+
// ✅ Update stays in transition
131+
// ✅ Non-blocking
132+
// ✅ Interruptible
133+
```
134+
135+
### Scenario 3: Component Mounts Mid-Transition
136+
137+
**useLiveQuery:**
138+
```
139+
Transition in progress: showing state A → B
140+
New component mounts
141+
├── Sees current state (B)
142+
└── ❌ TEARING! Shows B while others show A
143+
```
144+
145+
**useLiveQueryConcurrent:**
146+
```
147+
Transition in progress: showing state A → B
148+
New component mounts
149+
├── Sees committed state (A)
150+
├── Schedules update to pending state (B)
151+
└── ✅ No tearing! Synchronized with transition
152+
```
153+
154+
## Performance Comparison
155+
156+
| Aspect | useLiveQuery | useLiveQueryConcurrent |
157+
|--------|-------------|------------------------|
158+
| Initial mount | Fast | Slightly slower (store creation) |
159+
| Updates | Very fast | Fast (commit tracking overhead) |
160+
| Memory | Lower | Higher (dual snapshots) |
161+
| Re-renders | Minimal | Minimal (same optimization strategy) |
162+
| Provider overhead | None | Small (CommitTracker component) |
163+
164+
## Feature Comparison
165+
166+
| Feature | useLiveQuery | useLiveQueryConcurrent |
167+
|---------|-------------|------------------------|
168+
| Basic queries |||
169+
| Joins |||
170+
| Disabled queries |||
171+
| Dependencies |||
172+
| Pre-created collections |||
173+
| Works without provider || ❌ Requires provider |
174+
| React transitions | ❌ De-opts to sync | ✅ Fully supported |
175+
| Concurrent rendering | ❌ Forces sync | ✅ Concurrent-safe |
176+
| Tearing prevention | ⚠️ Can tear | ✅ Prevents tearing |
177+
| Future React alignment | Current standard | Future concurrent API |
178+
179+
## Migration Path
180+
181+
### Step 1: Add Provider
182+
183+
```diff
184+
function App() {
185+
return (
186+
+ <CollectionStoreProvider>
187+
<YourComponents />
188+
+ </CollectionStoreProvider>
189+
)
190+
}
191+
```
192+
193+
### Step 2: Replace Hook Import
194+
195+
```diff
196+
- import { useLiveQuery } from '@tanstack/react-db'
197+
+ import { useLiveQueryConcurrent as useLiveQuery } from '@tanstack/react-db/poc-store-pic'
198+
```
199+
200+
### Step 3: No other changes needed!
201+
202+
The API is identical, so all your existing queries work as-is.
203+
204+
## Trade-offs
205+
206+
### Advantages of POC
207+
208+
1. **Concurrent-safe**: Works properly with React transitions
209+
2. **No tearing**: Components mounting mid-transition see consistent state
210+
3. **Future-aligned**: Matches upcoming React concurrent stores API
211+
4. **Better UX**: Non-blocking updates with transitions
212+
213+
### Advantages of Current
214+
215+
1. **Simpler**: No provider required
216+
2. **More performant**: No commit tracking overhead
217+
3. **Better tested**: Battle-tested React hook
218+
4. **Lower memory**: Single snapshot instead of dual
219+
5. **Works today**: No experimental features
220+
221+
## Recommendations
222+
223+
### Use useLiveQuery (current) when:
224+
- You don't use React transitions
225+
- Performance is critical
226+
- You want maximum simplicity
227+
- You need proven, stable behavior
228+
229+
### Use useLiveQueryConcurrent (POC) when:
230+
- You use React transitions heavily
231+
- You experience tearing issues
232+
- You want non-blocking updates
233+
- You're okay with experimental patterns
234+
- You want to align with future React direction
235+
236+
## Real-World Impact
237+
238+
### Example: Search with Transitions
239+
240+
**Current (useLiveQuery):**
241+
```typescript
242+
// User types in search box with debounce
243+
const [search, setSearch] = useState('')
244+
const { data } = useLiveQuery(
245+
(q) => q.from({ items }).where(({ items }) => like(items.name, search)),
246+
[search]
247+
)
248+
249+
// Problem: When search updates, forces sync re-render
250+
// Can cause input to feel janky if data is large
251+
```
252+
253+
**POC (useLiveQueryConcurrent):**
254+
```typescript
255+
const [search, setSearch] = useState('')
256+
const [isPending, startTransition] = useTransition()
257+
const { data } = useLiveQueryConcurrent(
258+
(q) => q.from({ items }).where(({ items }) => like(items.name, search)),
259+
[search]
260+
)
261+
262+
const handleSearch = (value) => {
263+
startTransition(() => {
264+
setSearch(value)
265+
})
266+
}
267+
268+
// Solution: Search stays in transition
269+
// Input stays responsive while data updates in background
270+
// Shows pending state to user
271+
```
272+
273+
## Future Direction
274+
275+
When React's concurrent stores API is released:
276+
277+
1. The POC pattern aligns closely with the new API
278+
2. Migration would be straightforward
279+
3. Could replace useLiveQuery entirely or coexist as separate hooks
280+
4. Would gain official React support and optimizations
281+
282+
## Conclusion
283+
284+
The POC demonstrates a viable path forward for concurrent-safe reactive queries in TanStack/db. While the current `useLiveQuery` is excellent for most use cases, `useLiveQueryConcurrent` opens up new possibilities for complex UIs that need non-blocking updates and tearing prevention.

0 commit comments

Comments
 (0)