Skip to content

Commit aea9b50

Browse files
docs: update changeset and examples for in-batch deduplication only
Update documentation to reflect the simplified deduplication feature that only works within a single batch/queue cycle. Changes: - Rewrote changeset to describe in-batch deduplication only - Removed cross-batch specific documentation - Deleted useBatcherDedup example (cross-batch focused) - Updated useBatcher example to demonstrate in-batch deduplication - Added visual test interface with activity log The feature is now simpler and more focused: it prevents duplicates within the same batch but does not track across batch cycles.
1 parent 72223cc commit aea9b50

File tree

11 files changed

+132
-679
lines changed

11 files changed

+132
-679
lines changed
Lines changed: 26 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,57 +1,44 @@
11
---
2-
'@tanstack/pacer': minor
2+
"@tanstack/pacer": minor
33
---
44

5-
Add cross-batch/cross-execution deduplication support to Batcher and Queuer
5+
Add in-batch/in-queue deduplication support to Batcher and Queuer
66

7-
This feature extends the existing `deduplicateItems` option to track processed items across batch/execution cycles. When enabled, items that have already been processed will be automatically skipped.
7+
This feature adds `deduplicateItems` option to prevent duplicate items within the same batch or queue.
88

9-
### Enhanced Options
9+
### New Options
1010

11-
- `deduplicateItems: boolean` - Now prevents duplicates **both within and across batches** (default: false)
12-
- `deduplicateStrategy: 'keep-first' | 'keep-last'` - Only affects in-batch duplicates (default: 'keep-first')
13-
- `getItemKey: (item) => string | number` - Extract unique key from item
14-
- `maxTrackedKeys: number` - Maximum keys to track with FIFO eviction (default: 1000)
15-
- `onDuplicate: (newItem, existingItem?, instance) => void` - Called for both in-batch and cross-batch duplicates
16-
17-
### New Methods
18-
19-
- `hasProcessedKey(key)` - Check if a key has been processed
20-
- `peekProcessedKeys()` - Get a copy of all processed keys
21-
- `clearProcessedKeys()` - Clear the processed keys history
22-
23-
### New State Properties
24-
25-
- `processedKeys: Array<string | number>` - Keys that have been processed (similar to RateLimiter's executionTimes)
11+
- `deduplicateItems: boolean` - Enable automatic deduplication within the current batch/queue (default: false)
12+
- `deduplicateStrategy: 'keep-first' | 'keep-last'` - Strategy for handling duplicates (default: 'keep-first')
13+
- `getItemKey: (item) => string | number` - Extract unique key from item (defaults to JSON.stringify for objects)
2614

2715
### Behavior
2816

2917
When `deduplicateItems` is enabled:
18+
- **'keep-first'**: Ignores new items if an item with the same key already exists in the batch/queue
19+
- **'keep-last'**: Replaces existing items with new items that have the same key
3020

31-
1. **In-batch duplicates**: Merged based on `deduplicateStrategy` ('keep-first' or 'keep-last')
32-
2. **Cross-batch duplicates**: Skipped entirely (already processed)
33-
3. `onDuplicate` called with `existingItem` for in-batch, `undefined` for cross-batch
34-
35-
### Use Case
36-
37-
Prevents redundant processing when the same data is requested multiple times:
38-
39-
- API calls: Don't fetch user-123 if it was already fetched
40-
- No-code tools: Multiple components requesting the same resource
41-
- Event processing: Skip events that have already been handled
42-
43-
Similar to request deduplication in TanStack Query, but at the batching/queuing level.
21+
### Use Cases
4422

45-
### Persistence Support
23+
Prevents redundant items within a single batch or queue cycle:
24+
- API batching: Avoid duplicate IDs in the same batch request
25+
- Event processing: Deduplicate events before processing
4626

47-
The `processedKeys` can be persisted via `initialState`, following the existing Pacer pattern (similar to RateLimiter):
27+
### Example
4828

4929
```typescript
50-
const savedState = localStorage.getItem('batcher-state')
51-
const batcher = new Batcher(fn, {
52-
deduplicateItems: true,
53-
initialState: savedState ? JSON.parse(savedState) : {},
54-
})
30+
const batcher = new Batcher<{ userId: string }>(
31+
(items) => fetchUsers(items.map(i => i.userId)),
32+
{
33+
deduplicateItems: true,
34+
getItemKey: (item) => item.userId,
35+
}
36+
);
37+
38+
batcher.addItem({ userId: 'user-1' }); // Added to batch
39+
batcher.addItem({ userId: 'user-2' }); // Added to batch
40+
batcher.addItem({ userId: 'user-1' }); // Ignored! Already in current batch
41+
batcher.flush(); // Processes [user-1, user-2]
5542
```
5643

5744
Fully opt-in with no breaking changes to existing behavior.

examples/react/useBatcher/src/index.tsx

Lines changed: 106 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -6,30 +6,44 @@ import { PacerProvider } from '@tanstack/react-pacer/provider'
66
function App1() {
77
// Use your state management library of choice
88
const [processedBatches, setProcessedBatches] = useState<
9-
Array<Array<number>>
9+
Array<Array<string>>
1010
>([])
11+
const [log, setLog] = useState<string[]>([])
1112

1213
// The function that will process a batch of items
13-
function processBatch(items: Array<number>) {
14+
function processBatch(items: Array<string>) {
1415
setProcessedBatches((prev) => [...prev, items])
16+
setLog((prev) => [...prev, `Processed batch: [${items.join(', ')}]`])
1517
console.log('processing batch', items)
1618
}
1719

1820
const batcher = useBatcher(
1921
processBatch,
2022
{
21-
// started: false, // true by default
22-
maxSize: 5, // Process in batches of 5 (if comes before wait time)
23-
wait: 3000, // wait up to 3 seconds before processing a batch (if time elapses before maxSize is reached)
24-
getShouldExecute: (items, _batcher) => items.includes(42), // or pass in a custom function to determine if the batch should be processed
23+
maxSize: 5,
24+
wait: 3000,
25+
// Enable in-batch deduplication
26+
deduplicateItems: true,
27+
deduplicateStrategy: 'keep-first', // or 'keep-last'
2528
},
26-
// Alternative to batcher.Subscribe: pass a selector as 3rd arg to cause re-renders and subscribe to state
27-
// (state) => state,
2829
)
2930

31+
const addItem = (item: string) => {
32+
const result = batcher.addItem(item)
33+
if (result) {
34+
setLog((prev) => [...prev, `Added: ${item}`])
35+
} else {
36+
setLog((prev) => [...prev, `Duplicate ignored: ${item}`])
37+
}
38+
}
39+
3040
return (
31-
<div>
32-
<h1>TanStack Pacer useBatcher Example 1</h1>
41+
<div style={{ padding: '20px', fontFamily: 'sans-serif' }}>
42+
<h1>TanStack Pacer - In-Batch Deduplication Test</h1>
43+
<p style={{ color: '#666' }}>
44+
When <code>deduplicateItems: true</code>, duplicate items within the same batch are ignored.
45+
</p>
46+
3347
<batcher.Subscribe
3448
selector={(state) => ({
3549
size: state.size,
@@ -39,59 +53,93 @@ function App1() {
3953
>
4054
{({ size, executionCount, totalItemsProcessed }) => (
4155
<>
42-
<div>Batch Size: {size}</div>
43-
<div>Batch Max Size: {3}</div>
44-
<div>Batch Items: {batcher.peekAllItems().join(', ')}</div>
45-
<div>Batches Processed: {executionCount}</div>
46-
<div>Items Processed: {totalItemsProcessed}</div>
47-
<div>
48-
Processed Batches:{' '}
49-
{processedBatches.map((b, i) => (
50-
<>
51-
<span key={i}>[{b.join(', ')}]</span>,{' '}
52-
</>
53-
))}
56+
<div style={{ background: '#f5f5f5', padding: '15px', borderRadius: '8px', marginBottom: '20px' }}>
57+
<div><strong>Batch Size:</strong> {size}</div>
58+
<div><strong>Current Batch Items:</strong> [{batcher.peekAllItems().join(', ')}]</div>
59+
<div><strong>Batches Processed:</strong> {executionCount}</div>
60+
<div><strong>Total Items Processed:</strong> {totalItemsProcessed}</div>
5461
</div>
55-
<div
56-
style={{
57-
display: 'grid',
58-
gridTemplateColumns: 'repeat(2, 1fr)',
59-
gap: '8px',
60-
maxWidth: '600px',
61-
margin: '16px 0',
62-
}}
63-
>
64-
<button
65-
onClick={() => {
66-
const nextNumber = batcher.peekAllItems().length
67-
? batcher.peekAllItems()[
68-
batcher.peekAllItems().length - 1
69-
] + 1
70-
: 1
71-
batcher.addItem(nextNumber)
72-
}}
73-
>
74-
Add Number
75-
</button>
76-
<button
77-
disabled={size === 0}
78-
onClick={() => {
79-
batcher.flush()
80-
}}
81-
>
82-
Flush Current Batch
83-
</button>
62+
63+
<div style={{ marginBottom: '20px' }}>
64+
<h3>Test Deduplication</h3>
65+
<p style={{ fontSize: '14px', color: '#666' }}>
66+
Click the same button multiple times - duplicates within the batch will be ignored.
67+
</p>
68+
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap', marginBottom: '10px' }}>
69+
<button onClick={() => addItem('apple')} style={{ padding: '8px 16px' }}>
70+
Add "apple"
71+
</button>
72+
<button onClick={() => addItem('banana')} style={{ padding: '8px 16px' }}>
73+
Add "banana"
74+
</button>
75+
<button onClick={() => addItem('cherry')} style={{ padding: '8px 16px' }}>
76+
Add "cherry"
77+
</button>
78+
<button onClick={() => addItem('apple')} style={{ padding: '8px 16px', background: '#ffcccc' }}>
79+
Add "apple" (duplicate test)
80+
</button>
81+
</div>
82+
<div style={{ display: 'flex', gap: '8px' }}>
83+
<button
84+
disabled={size === 0}
85+
onClick={() => batcher.flush()}
86+
style={{ padding: '8px 16px', background: '#4CAF50', color: 'white', border: 'none', borderRadius: '4px' }}
87+
>
88+
Flush Batch
89+
</button>
90+
<button
91+
onClick={() => {
92+
batcher.reset()
93+
setProcessedBatches([])
94+
setLog([])
95+
}}
96+
style={{ padding: '8px 16px', background: '#f44336', color: 'white', border: 'none', borderRadius: '4px' }}
97+
>
98+
Reset All
99+
</button>
100+
</div>
101+
</div>
102+
103+
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '20px' }}>
104+
<div>
105+
<h4>Processed Batches</h4>
106+
<div style={{ background: '#e8f5e9', padding: '10px', borderRadius: '4px', minHeight: '100px' }}>
107+
{processedBatches.length === 0 ? (
108+
<span style={{ color: '#999' }}>No batches processed yet</span>
109+
) : (
110+
processedBatches.map((b, i) => (
111+
<div key={i}>Batch #{i + 1}: [{b.join(', ')}]</div>
112+
))
113+
)}
114+
</div>
115+
</div>
116+
<div>
117+
<h4>Activity Log</h4>
118+
<div style={{ background: '#fff3e0', padding: '10px', borderRadius: '4px', minHeight: '100px', maxHeight: '200px', overflow: 'auto' }}>
119+
{log.length === 0 ? (
120+
<span style={{ color: '#999' }}>No activity yet</span>
121+
) : (
122+
log.map((entry, i) => (
123+
<div key={i} style={{ fontSize: '12px', fontFamily: 'monospace' }}>{entry}</div>
124+
))
125+
)}
126+
</div>
127+
</div>
84128
</div>
85129
</>
86130
)}
87131
</batcher.Subscribe>
88-
<batcher.Subscribe selector={(state) => state}>
89-
{(state) => (
90-
<pre style={{ marginTop: '20px' }}>
91-
{JSON.stringify(state, null, 2)}
92-
</pre>
93-
)}
94-
</batcher.Subscribe>
132+
133+
<details style={{ marginTop: '20px' }}>
134+
<summary style={{ cursor: 'pointer', fontWeight: 'bold' }}>Debug: Full State</summary>
135+
<batcher.Subscribe selector={(state) => state}>
136+
{(state) => (
137+
<pre style={{ marginTop: '10px', padding: '10px', background: '#f1f3f5', borderRadius: '4px', overflow: 'auto' }}>
138+
{JSON.stringify(state, null, 2)}
139+
</pre>
140+
)}
141+
</batcher.Subscribe>
142+
</details>
95143
</div>
96144
)
97145
}

examples/react/useBatcherDedup/.eslintrc.cjs

Lines changed: 0 additions & 13 deletions
This file was deleted.

examples/react/useBatcherDedup/.gitignore

Lines changed: 0 additions & 27 deletions
This file was deleted.

0 commit comments

Comments
 (0)