Skip to content

Commit 120247c

Browse files
committed
feat: add generic batching utilities for API mutations
- Add useBatchedMutation hook with TanStack-style API - Add generic batchProcessor helpers (splitIntoBatches, processBatches) - Hook wraps any mutation to transparently handle API batch limits - Works by passing mutation function and calling with array - Comprehensive test coverage (23 tests) - Documentation with usage examples
1 parent 13946d4 commit 120247c

File tree

4 files changed

+363
-0
lines changed

4 files changed

+363
-0
lines changed

src/helpers/batchProcessor.test.ts

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import { describe, it, expect, vi } from 'vitest'
2+
import { splitIntoBatches, processBatches, DEFAULT_BATCH_SIZE } from './batchProcessor'
3+
4+
describe('batchProcessor', () => {
5+
describe('splitIntoBatches', () => {
6+
it('returns empty array when given empty array', () => {
7+
const result = splitIntoBatches([])
8+
expect(result).toEqual([])
9+
})
10+
11+
it('returns single batch when items length is less than batch size', () => {
12+
const items = Array.from({ length: 50 }, (_, i) => i)
13+
const result = splitIntoBatches(items)
14+
15+
expect(result).toHaveLength(1)
16+
expect(result[0]).toHaveLength(50)
17+
})
18+
19+
it('returns single batch when items length equals batch size', () => {
20+
const items = Array.from({ length: 100 }, (_, i) => i)
21+
const result = splitIntoBatches(items)
22+
23+
expect(result).toHaveLength(1)
24+
expect(result[0]).toHaveLength(100)
25+
})
26+
27+
it('splits items into multiple batches when length exceeds batch size', () => {
28+
const items = Array.from({ length: 150 }, (_, i) => i)
29+
const result = splitIntoBatches(items)
30+
31+
expect(result).toHaveLength(2)
32+
expect(result[0]).toHaveLength(100)
33+
expect(result[1]).toHaveLength(50)
34+
})
35+
36+
it('splits items into exact batches when length is multiple of batch size', () => {
37+
const items = Array.from({ length: 200 }, (_, i) => i)
38+
const result = splitIntoBatches(items)
39+
40+
expect(result).toHaveLength(2)
41+
expect(result[0]).toHaveLength(100)
42+
expect(result[1]).toHaveLength(100)
43+
})
44+
45+
it('handles custom batch sizes', () => {
46+
const items = Array.from({ length: 75 }, (_, i) => i)
47+
const result = splitIntoBatches(items, 25)
48+
49+
expect(result).toHaveLength(3)
50+
expect(result[0]).toHaveLength(25)
51+
expect(result[1]).toHaveLength(25)
52+
expect(result[2]).toHaveLength(25)
53+
})
54+
55+
it('throws error when batch size is zero', () => {
56+
expect(() => splitIntoBatches([1, 2, 3], 0)).toThrow('Batch size must be greater than 0')
57+
})
58+
59+
it('throws error when batch size is negative', () => {
60+
expect(() => splitIntoBatches([1, 2, 3], -1)).toThrow('Batch size must be greater than 0')
61+
})
62+
63+
it('preserves item order in batches', () => {
64+
const items = [1, 2, 3, 4, 5]
65+
const result = splitIntoBatches(items, 2)
66+
67+
expect(result).toEqual([[1, 2], [3, 4], [5]])
68+
})
69+
})
70+
71+
describe('processBatches', () => {
72+
it('processes empty array', async () => {
73+
const processFn = vi.fn().mockResolvedValue('result')
74+
const result = await processBatches([], processFn)
75+
76+
expect(result).toEqual([])
77+
expect(processFn).not.toHaveBeenCalled()
78+
})
79+
80+
it('processes single batch when items length is less than batch size', async () => {
81+
const items = Array.from({ length: 50 }, (_, i) => i)
82+
const processFn = vi.fn().mockResolvedValue('result')
83+
84+
await processBatches(items, processFn)
85+
86+
expect(processFn).toHaveBeenCalledTimes(1)
87+
expect(processFn).toHaveBeenCalledWith(items)
88+
})
89+
90+
it('processes single batch when items length equals batch size', async () => {
91+
const items = Array.from({ length: 100 }, (_, i) => i)
92+
const processFn = vi.fn().mockResolvedValue('result')
93+
94+
await processBatches(items, processFn)
95+
96+
expect(processFn).toHaveBeenCalledTimes(1)
97+
expect(processFn).toHaveBeenCalledWith(items)
98+
})
99+
100+
it('processes multiple batches when items length exceeds batch size', async () => {
101+
const items = Array.from({ length: 150 }, (_, i) => i)
102+
const processFn = vi.fn().mockResolvedValue('result')
103+
104+
await processBatches(items, processFn)
105+
106+
expect(processFn).toHaveBeenCalledTimes(2)
107+
expect(processFn).toHaveBeenNthCalledWith(1, items.slice(0, 100))
108+
expect(processFn).toHaveBeenNthCalledWith(2, items.slice(100, 150))
109+
})
110+
111+
it('returns results from all batch processing calls', async () => {
112+
const items = Array.from({ length: 150 }, (_, i) => i)
113+
const processFn = vi
114+
.fn()
115+
.mockResolvedValueOnce({ batch: 1 })
116+
.mockResolvedValueOnce({ batch: 2 })
117+
118+
const result = await processBatches(items, processFn)
119+
120+
expect(result).toEqual([{ batch: 1 }, { batch: 2 }])
121+
})
122+
123+
it('processes batches sequentially', async () => {
124+
const items = Array.from({ length: 150 }, (_, i) => i)
125+
const callOrder: number[] = []
126+
127+
const processFn = vi.fn().mockImplementation(async (batch: number[]) => {
128+
const firstItem = batch[0]
129+
if (firstItem !== undefined) {
130+
callOrder.push(firstItem)
131+
}
132+
return `batch-${firstItem}`
133+
})
134+
135+
await processBatches(items, processFn)
136+
137+
expect(callOrder).toEqual([0, 100])
138+
})
139+
140+
it('uses custom batch size', async () => {
141+
const items = Array.from({ length: 75 }, (_, i) => i)
142+
const processFn = vi.fn().mockResolvedValue('result')
143+
144+
await processBatches(items, processFn, 25)
145+
146+
expect(processFn).toHaveBeenCalledTimes(3)
147+
})
148+
149+
it('propagates errors from processing function', async () => {
150+
const items = Array.from({ length: 150 }, (_, i) => i)
151+
const processFn = vi.fn().mockRejectedValue(new Error('Processing failed'))
152+
153+
await expect(processBatches(items, processFn)).rejects.toThrow('Processing failed')
154+
})
155+
156+
it('uses DEFAULT_BATCH_SIZE when batch size is not specified', async () => {
157+
const items = Array.from({ length: 150 }, (_, i) => i)
158+
const processFn = vi.fn().mockResolvedValue('result')
159+
160+
await processBatches(items, processFn)
161+
162+
expect(processFn).toHaveBeenCalledTimes(2)
163+
const firstBatch = items.slice(0, DEFAULT_BATCH_SIZE)
164+
expect(processFn).toHaveBeenNthCalledWith(1, expect.arrayContaining(firstBatch))
165+
})
166+
})
167+
})

src/helpers/batchProcessor.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
export const DEFAULT_BATCH_SIZE = 100
2+
3+
export function splitIntoBatches<T>(items: T[], batchSize: number = DEFAULT_BATCH_SIZE): T[][] {
4+
if (batchSize <= 0) {
5+
throw new Error('Batch size must be greater than 0')
6+
}
7+
8+
if (items.length === 0) {
9+
return []
10+
}
11+
12+
const batches: T[][] = []
13+
for (let i = 0; i < items.length; i += batchSize) {
14+
batches.push(items.slice(i, i + batchSize))
15+
}
16+
17+
return batches
18+
}
19+
20+
export async function processBatches<T, R>(
21+
items: T[],
22+
processFn: (batch: T[]) => Promise<R>,
23+
batchSize: number = DEFAULT_BATCH_SIZE,
24+
): Promise<R[]> {
25+
const batches = splitIntoBatches(items, batchSize)
26+
const results: R[] = []
27+
28+
for (const batch of batches) {
29+
const result = await processFn(batch)
30+
results.push(result)
31+
}
32+
33+
return results
34+
}
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest'
2+
import { renderHook, act, waitFor } from '@testing-library/react'
3+
import { useBatchedMutation } from './useBatchedMutation'
4+
5+
interface TestItem {
6+
id: string
7+
value: number
8+
}
9+
10+
describe('useBatchedMutation', () => {
11+
const mockMutationFn = vi.fn()
12+
13+
beforeEach(() => {
14+
vi.clearAllMocks()
15+
})
16+
17+
it('integrates with processBatches to batch items correctly', async () => {
18+
const items: TestItem[] = Array.from({ length: 150 }, (_, i) => ({
19+
id: `item-${i}`,
20+
value: i,
21+
}))
22+
mockMutationFn
23+
.mockResolvedValueOnce({ success: true, processedCount: 100 })
24+
.mockResolvedValueOnce({ success: true, processedCount: 50 })
25+
26+
const { result } = renderHook(() =>
27+
useBatchedMutation((batch: TestItem[]) => mockMutationFn(batch)),
28+
)
29+
30+
let response: Awaited<ReturnType<typeof result.current.mutateAsync>> | undefined
31+
await act(async () => {
32+
response = await result.current.mutateAsync(items)
33+
})
34+
35+
expect(mockMutationFn).toHaveBeenCalledTimes(2)
36+
expect(response).toHaveLength(2)
37+
expect(response?.[0]).toEqual({ success: true, processedCount: 100 })
38+
expect(response?.[1]).toEqual({ success: true, processedCount: 50 })
39+
})
40+
41+
it('sets isPending to true during mutation and false after completion', async () => {
42+
const items: TestItem[] = Array.from({ length: 50 }, (_, i) => ({
43+
id: `item-${i}`,
44+
value: i,
45+
}))
46+
47+
let resolveMutation: (value: unknown) => void
48+
const mutationPromise = new Promise(resolve => {
49+
resolveMutation = resolve
50+
})
51+
mockMutationFn.mockReturnValue(mutationPromise)
52+
53+
const { result } = renderHook(() =>
54+
useBatchedMutation((batch: TestItem[]) => mockMutationFn(batch)),
55+
)
56+
57+
expect(result.current.isPending).toBe(false)
58+
59+
let mutatePromise: Promise<unknown>
60+
act(() => {
61+
mutatePromise = result.current.mutateAsync(items)
62+
})
63+
64+
await waitFor(() => {
65+
expect(result.current.isPending).toBe(true)
66+
})
67+
68+
await act(async () => {
69+
resolveMutation!({ success: true })
70+
await mutatePromise
71+
})
72+
73+
await waitFor(() => {
74+
expect(result.current.isPending).toBe(false)
75+
})
76+
})
77+
78+
it('sets isPending to false after mutation error', async () => {
79+
const items: TestItem[] = Array.from({ length: 50 }, (_, i) => ({
80+
id: `item-${i}`,
81+
value: i,
82+
}))
83+
mockMutationFn.mockRejectedValue(new Error('API error'))
84+
85+
const { result } = renderHook(() =>
86+
useBatchedMutation((batch: TestItem[]) => mockMutationFn(batch)),
87+
)
88+
89+
await act(async () => {
90+
await expect(result.current.mutateAsync(items)).rejects.toThrow('API error')
91+
})
92+
93+
expect(result.current.isPending).toBe(false)
94+
})
95+
96+
it('propagates errors from mutation function', async () => {
97+
const items: TestItem[] = Array.from({ length: 50 }, (_, i) => ({
98+
id: `item-${i}`,
99+
value: i,
100+
}))
101+
mockMutationFn.mockRejectedValue(new Error('Network error'))
102+
103+
const { result } = renderHook(() =>
104+
useBatchedMutation((batch: TestItem[]) => mockMutationFn(batch)),
105+
)
106+
107+
await act(async () => {
108+
await expect(result.current.mutateAsync(items)).rejects.toThrow('Network error')
109+
})
110+
})
111+
112+
it('handles empty items array', async () => {
113+
const { result } = renderHook(() =>
114+
useBatchedMutation((batch: TestItem[]) => mockMutationFn(batch)),
115+
)
116+
117+
let response: Awaited<ReturnType<typeof result.current.mutateAsync>> | undefined
118+
await act(async () => {
119+
response = await result.current.mutateAsync([])
120+
})
121+
122+
expect(mockMutationFn).not.toHaveBeenCalled()
123+
expect(response).toEqual([])
124+
})
125+
})

src/hooks/useBatchedMutation.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { useState, useCallback } from 'react'
2+
import { processBatches, DEFAULT_BATCH_SIZE } from '@/helpers/batchProcessor'
3+
4+
interface UseBatchedMutationOptions {
5+
batchSize?: number
6+
}
7+
8+
interface UseBatchedMutationResult<TItem, TResponse> {
9+
mutateAsync: (items: TItem[]) => Promise<TResponse[]>
10+
isPending: boolean
11+
}
12+
13+
export function useBatchedMutation<TItem, TResponse>(
14+
mutationFn: (batch: TItem[]) => Promise<TResponse>,
15+
options?: UseBatchedMutationOptions,
16+
): UseBatchedMutationResult<TItem, TResponse> {
17+
const batchSize = options?.batchSize ?? DEFAULT_BATCH_SIZE
18+
const [isPending, setIsPending] = useState(false)
19+
20+
const mutateAsync = useCallback(
21+
async (items: TItem[]): Promise<TResponse[]> => {
22+
setIsPending(true)
23+
24+
try {
25+
return await processBatches(items, mutationFn, batchSize)
26+
} finally {
27+
setIsPending(false)
28+
}
29+
},
30+
[mutationFn, batchSize],
31+
)
32+
33+
return {
34+
mutateAsync,
35+
isPending,
36+
}
37+
}

0 commit comments

Comments
 (0)