Skip to content

Commit b367946

Browse files
authored
Merge pull request #2846 from reduxjs/feature/auto-batch-enhancer-again
2 parents d934fad + cca9779 commit b367946

File tree

7 files changed

+387
-19
lines changed

7 files changed

+387
-19
lines changed

docs/api/autoBatchEnhancer.mdx

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
---
2+
id: autoBatchEnhancer
3+
title: autoBatchEnhancer
4+
sidebar_label: autoBatchEnhancer
5+
hide_title: true
6+
---
7+
8+
 
9+
10+
# `autoBatchEnhancer`
11+
12+
A Redux store enhancer that looks for one or more "low-priority" dispatched actions in a row, and delays notifying subscribers until either the end of the current event loop tick or when the next "normal-priority" action is dispatched.
13+
14+
## Basic Usage
15+
16+
```ts
17+
import {
18+
createSlice,
19+
configureStore,
20+
autoBatchEnhancer,
21+
prepareAutoBatched,
22+
} from '@reduxjs/toolkit'
23+
24+
interface CounterState {
25+
value: number
26+
}
27+
28+
const counterSlice = createSlice({
29+
name: 'counter',
30+
initialState: { value: 0 } as CounterState,
31+
reducers: {
32+
incrementBatched: {
33+
// Batched, low-priority
34+
reducer(state) {
35+
state.value += 1
36+
},
37+
// highlight-start
38+
// Use the `prepareAutoBatched` utility to automatically
39+
// add the `action.meta[SHOULD_AUTOBATCH]` field the enhancer needs
40+
prepare: prepareAutoBatched<void>(),
41+
// highlight-end
42+
},
43+
// Not batched, normal priority
44+
decrementUnbatched(state) {
45+
state.value -= 1
46+
},
47+
},
48+
})
49+
const { incrementBatched, decrementUnbatched } = counterSlice.actions
50+
51+
const store = configureStore({
52+
reducer: counterSlice.reducer,
53+
// highlight-start
54+
enhancers: (existingEnhancers) => {
55+
// Add the autobatch enhancer to the store setup
56+
return existingEnhancers.concat(autoBatchEnhancer())
57+
},
58+
// highlight-end
59+
})
60+
```
61+
62+
## API
63+
64+
### `autoBatchEnhancer`
65+
66+
```ts title="autoBatchEnhancer signature" no-transpile
67+
export type SHOULD_AUTOBATCH = string
68+
export type autoBatchEnhancer = () => StoreEnhancer
69+
```
70+
71+
Creates a new instance of the autobatch store enhancer.
72+
73+
Any action that is tagged with `action.meta[SHOULD_AUTOBATCH] = true` will be treated as "low-priority", and the enhancer will delay notifying subscribers until either:
74+
75+
- The end of the current event loop tick happens, and a queued microtask runs the notifications
76+
- A "normal-priority" action (any action _without_ `action.meta[SHOULD_AUTOBATCH] = true`) is dispatched in the same tick
77+
78+
This method currently does not accept any options. We may consider allowing customization of the delay behavior in the future.
79+
80+
The `SHOULD_AUTOBATCH` value is meant to be opaque - it's currently a string for simplicity, but could be a `Symbol` in the future.
81+
82+
### `prepareAutoBatched`
83+
84+
```ts title="prepareAutoBatched signature" no-transpile
85+
type prepareAutoBatched = <T>() => (payload: T) => { payload: T; meta: unknown }
86+
```
87+
88+
Creates a function that accepts a `payload` value, and returns an object with `{payload, meta: {[SHOULD_AUTOBATCH]: true}}`. This is meant to be used with RTK's `createSlice` and its "`prepare` callback" syntax:
89+
90+
```ts no-transpile
91+
createSlice({
92+
name: 'todos',
93+
initialState,
94+
reducers: {
95+
todoAdded: {
96+
reducer(state, action: PayloadAction<Todo>) {
97+
state.push(action.payload)
98+
},
99+
// highlight-start
100+
prepare: prepareAutoBatched<Todo>(),
101+
// highlight-end
102+
},
103+
},
104+
})
105+
```
106+
107+
## Batching Approach and Background
108+
109+
The post [A Comparison of Redux Batching Techniques](https://blog.isquaredsoftware.com/2020/01/blogged-answers-redux-batching-techniques/) describes four different approaches for "batching Redux actions/dispatches"
110+
111+
- a higher-order reducer that accepts multiple actions nested inside one real action, and iterates over them together
112+
- an enhancer that wraps `dispatch` and debounces the notification callback
113+
- an enhancer that wraps `dispatch` to accept an array of actions
114+
- React's `unstable_batchedUpdates()`, which just combines multiple queued renders into one but doesn't affect subscriber notifications
115+
116+
This enhancer is a variation of the "debounce" approach, but with a twist.
117+
118+
Instead of _just_ debouncing _all_ subscriber notifications, it watches for any actions with a specific `action.meta[SHOULD_AUTOBATCH]: true` field attached.
119+
120+
When it sees an action with that field, it queues a microtask. The reducer is updated immediately, but the enhancer does _not_ notify subscribers right way. If other actions with the same field are dispatched in succession, the enhancer will continue to _not_ notify subscribers. Then, when the queued microtask runs at the end of the event loop tick, it finally notifies all subscribers, similar to how React batches re-renders.
121+
122+
The additional twist is also inspired by React's separation of updates into "low-priority" and "immediate" behavior (such as a render queued by an AJAX request vs a render queued by a user input that should be handled synchronously).
123+
124+
If some low-pri actions have been dispatched and a notification microtask is queued, then a _normal_ priority action (without the field) is dispatched, the enhancer will go ahead and notify all subscribers synchronously as usual, and _not_ notify them at the end of the tick.
125+
126+
This allows Redux users to selectively tag certain actions for effective batching behavior, making this purely opt-in on a per-action basis, while retaining normal notification behavior for all other actions.
127+
128+
### RTK Query and Batching
129+
130+
RTK Query already marks several of its key internal action types as batchable. If you add the `autoBatchEnhancer` to the store setup, it will improve the overall UI performance, especially when rendering large lists of components that use the RTKQ query hooks.
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import type { StoreEnhancer } from 'redux'
2+
3+
export const SHOULD_AUTOBATCH = 'RTK_autoBatch'
4+
5+
export const prepareAutoBatched =
6+
<T>() =>
7+
(payload: T): { payload: T; meta: unknown } => ({
8+
payload,
9+
meta: { [SHOULD_AUTOBATCH]: true },
10+
})
11+
12+
// TODO Remove this in 2.0
13+
// Copied from https://github.com/feross/queue-microtask
14+
let promise: Promise<any>
15+
const queueMicrotaskShim =
16+
typeof queueMicrotask === 'function'
17+
? queueMicrotask.bind(typeof window !== 'undefined' ? window : global)
18+
: // reuse resolved promise, and allocate it lazily
19+
(cb: () => void) =>
20+
(promise || (promise = Promise.resolve())).then(cb).catch((err: any) =>
21+
setTimeout(() => {
22+
throw err
23+
}, 0)
24+
)
25+
26+
/**
27+
* A Redux store enhancer that watches for "low-priority" actions, and delays
28+
* notifying subscribers until either the end of the event loop tick or the
29+
* next "standard-priority" action is dispatched.
30+
*
31+
* This allows dispatching multiple "low-priority" actions in a row with only
32+
* a single subscriber notification to the UI after the sequence of actions
33+
* is finished, thus improving UI re-render performance.
34+
*
35+
* Watches for actions with the `action.meta[SHOULD_AUTOBATCH]` attribute.
36+
* This can be added to `action.meta` manually, or by using the
37+
* `prepareAutoBatched` helper.
38+
*
39+
*/
40+
export const autoBatchEnhancer =
41+
(): StoreEnhancer =>
42+
(next) =>
43+
(...args) => {
44+
const store = next(...args)
45+
46+
let notifying = true
47+
let shouldNotifyAtEndOfTick = false
48+
let notificationQueued = false
49+
50+
const listeners = new Set<() => void>()
51+
52+
const notifyListeners = () => {
53+
// We're running at the end of the event loop tick.
54+
// Run the real listener callbacks to actually update the UI.
55+
notificationQueued = false
56+
if (shouldNotifyAtEndOfTick) {
57+
shouldNotifyAtEndOfTick = false
58+
listeners.forEach((l) => l())
59+
}
60+
}
61+
62+
return Object.assign({}, store, {
63+
// Override the base `store.subscribe` method to keep original listeners
64+
// from running if we're delaying notifications
65+
subscribe(listener: () => void) {
66+
// Each wrapped listener will only call the real listener if
67+
// the `notifying` flag is currently active when it's called.
68+
// This lets the base store work as normal, while the actual UI
69+
// update becomes controlled by this enhancer.
70+
const wrappedListener: typeof listener = () => notifying && listener()
71+
const unsubscribe = store.subscribe(wrappedListener)
72+
listeners.add(listener)
73+
return () => {
74+
unsubscribe()
75+
listeners.delete(listener)
76+
}
77+
},
78+
// Override the base `store.dispatch` method so that we can check actions
79+
// for the `shouldAutoBatch` flag and determine if batching is active
80+
dispatch(action: any) {
81+
try {
82+
// If the action does _not_ have the `shouldAutoBatch` flag,
83+
// we resume/continue normal notify-after-each-dispatch behavior
84+
notifying = !action?.meta?.[SHOULD_AUTOBATCH]
85+
// If a `notifyListeners` microtask was queued, you can't cancel it.
86+
// Instead, we set a flag so that it's a no-op when it does run
87+
shouldNotifyAtEndOfTick = !notifying
88+
if (shouldNotifyAtEndOfTick) {
89+
// We've seen at least 1 action with `SHOULD_AUTOBATCH`. Try to queue
90+
// a microtask to notify listeners at the end of the event loop tick.
91+
// Make sure we only enqueue this _once_ per tick.
92+
if (!notificationQueued) {
93+
notificationQueued = true
94+
queueMicrotaskShim(notifyListeners)
95+
}
96+
}
97+
// Go ahead and process the action as usual, including reducers.
98+
// If normal notification behavior is enabled, the store will notify
99+
// all of its own listeners, and the wrapper callbacks above will
100+
// see `notifying` is true and pass on to the real listener callbacks.
101+
// If we're "batching" behavior, then the wrapped callbacks will
102+
// bail out, causing the base store notification behavior to be no-ops.
103+
return store.dispatch(action)
104+
} finally {
105+
// Assume we're back to normal behavior after each action
106+
notifying = true
107+
}
108+
},
109+
})
110+
}

packages/toolkit/src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,3 +184,9 @@ export {
184184
clearAllListeners,
185185
TaskAbortError,
186186
} from './listenerMiddleware/index'
187+
188+
export {
189+
SHOULD_AUTOBATCH,
190+
prepareAutoBatched,
191+
autoBatchEnhancer,
192+
} from './autoBatchEnhancer'

packages/toolkit/src/query/core/buildSlice.ts

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
isFulfilled,
88
isRejectedWithValue,
99
createNextState,
10+
prepareAutoBatched,
1011
} from '@reduxjs/toolkit'
1112
import type {
1213
CombinedState as CombinedQueryState,
@@ -114,11 +115,14 @@ export function buildSlice({
114115
name: `${reducerPath}/queries`,
115116
initialState: initialState as QueryState<any>,
116117
reducers: {
117-
removeQueryResult(
118-
draft,
119-
{ payload: { queryCacheKey } }: PayloadAction<QuerySubstateIdentifier>
120-
) {
121-
delete draft[queryCacheKey]
118+
removeQueryResult: {
119+
reducer(
120+
draft,
121+
{ payload: { queryCacheKey } }: PayloadAction<QuerySubstateIdentifier>
122+
) {
123+
delete draft[queryCacheKey]
124+
},
125+
prepare: prepareAutoBatched<QuerySubstateIdentifier>(),
122126
},
123127
queryResultPatched(
124128
draft,
@@ -243,14 +247,14 @@ export function buildSlice({
243247
name: `${reducerPath}/mutations`,
244248
initialState: initialState as MutationState<any>,
245249
reducers: {
246-
removeMutationResult(
247-
draft,
248-
{ payload }: PayloadAction<MutationSubstateIdentifier>
249-
) {
250-
const cacheKey = getMutationCacheKey(payload)
251-
if (cacheKey in draft) {
252-
delete draft[cacheKey]
253-
}
250+
removeMutationResult: {
251+
reducer(draft, { payload }: PayloadAction<MutationSubstateIdentifier>) {
252+
const cacheKey = getMutationCacheKey(payload)
253+
if (cacheKey in draft) {
254+
delete draft[cacheKey]
255+
}
256+
},
257+
prepare: prepareAutoBatched<MutationSubstateIdentifier>(),
254258
},
255259
},
256260
extraReducers(builder) {

packages/toolkit/src/query/core/buildThunks.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ import type {
3939
ThunkDispatch,
4040
AsyncThunk,
4141
} from '@reduxjs/toolkit'
42-
import { createAsyncThunk } from '@reduxjs/toolkit'
42+
import { createAsyncThunk, SHOULD_AUTOBATCH } from '@reduxjs/toolkit'
4343

4444
import { HandledError } from '../HandledError'
4545

@@ -123,13 +123,18 @@ export interface MutationThunkArg {
123123
export type ThunkResult = unknown
124124

125125
export type ThunkApiMetaConfig = {
126-
pendingMeta: { startedTimeStamp: number }
126+
pendingMeta: {
127+
startedTimeStamp: number
128+
[SHOULD_AUTOBATCH]: true
129+
}
127130
fulfilledMeta: {
128131
fulfilledTimeStamp: number
129132
baseQueryMeta: unknown
133+
[SHOULD_AUTOBATCH]: true
130134
}
131135
rejectedMeta: {
132136
baseQueryMeta: unknown
137+
[SHOULD_AUTOBATCH]: true
133138
}
134139
}
135140
export type QueryThunk = AsyncThunk<
@@ -399,6 +404,7 @@ export function buildThunks<
399404
{
400405
fulfilledTimeStamp: Date.now(),
401406
baseQueryMeta: result.meta,
407+
[SHOULD_AUTOBATCH]: true,
402408
}
403409
)
404410
} catch (error) {
@@ -423,7 +429,7 @@ export function buildThunks<
423429
catchedError.meta,
424430
arg.originalArgs
425431
),
426-
{ baseQueryMeta: catchedError.meta }
432+
{ baseQueryMeta: catchedError.meta, [SHOULD_AUTOBATCH]: true }
427433
)
428434
} catch (e) {
429435
catchedError = e
@@ -473,7 +479,7 @@ In the case of an unhandled error, no tags will be "provided" or "invalidated".`
473479
ThunkApiMetaConfig & { state: RootState<any, string, ReducerPath> }
474480
>(`${reducerPath}/executeQuery`, executeEndpoint, {
475481
getPendingMeta() {
476-
return { startedTimeStamp: Date.now() }
482+
return { startedTimeStamp: Date.now(), [SHOULD_AUTOBATCH]: true }
477483
},
478484
condition(queryThunkArgs, { getState }) {
479485
const state = getState()
@@ -532,7 +538,7 @@ In the case of an unhandled error, no tags will be "provided" or "invalidated".`
532538
ThunkApiMetaConfig & { state: RootState<any, string, ReducerPath> }
533539
>(`${reducerPath}/executeMutation`, executeEndpoint, {
534540
getPendingMeta() {
535-
return { startedTimeStamp: Date.now() }
541+
return { startedTimeStamp: Date.now(), [SHOULD_AUTOBATCH]: true }
536542
},
537543
})
538544

0 commit comments

Comments
 (0)