Batching
Zedux flushes updates synchronously. It needs to do this to keep user input in sync with state for optimal UX. But this can lead to unnecessary evaluations when setting the state of multiple stores at once.
These extra evaluations are sometimes an unnecessary performance loss. They can also be unexpected, leading to values seeming out of sync during the in-between evaluations. This is a form of "state tearing" and can even lead to bugs in rare cases.
To combat all this, Zedux offers batching APIs. It also batches some things by default.
- How to batch a known set of updates.
- How to batch open-endedly.
- When Zedux automatically batches updates.
Batching Known Updates
When you have a piece of code with multiple synchronous .setState()
or similar calls, you can wrap the whole thing in ecosystem.batch()
to prevent Zedux from evaluating any atoms until all state is updated.
// before:
anInstance.setState(newState)
aStore.setStateDeep({ newValue })
// after:
ecosystem.batch(() => {
anInstance.setState(newState)
aStore.setStateDeep({ newValue })
})
ecosystem.batch()
prevents the scheduler from flushing until the callback completes. This is the simplest way to batch updates.
Open-Ended Batching
Sometimes it isn't convenient to wrap synchronous dispatches in a single ecosystem.batch()
call. For these situations, you can set the meta
property of the dispatched action to zeduxTypes.batch
.
// before:
anInstance.setState(newState)
aStore.setStateDeep({ newValue })
// after:
anInstance.setState(newState, zeduxTypes.batch)
aStore.setStateDeep({ newValue }, zeduxTypes.batch)
When Zedux sees zeduxTypes.batch
, it doesn't flush immediately. Instead, it sets a timeout to flush on the next event loop cycle. If any non-batching dispatch happens during the current event loop cycle, it will trigger a flush - canceling the timeout and batching itself with any previously-batched state updates.
// batches:
anInstance.setState(newState, zeduxTypes.batch)
// flushes the above state update (and itself):
aStore.setStateDeep({ newValue })
Automatic Batching
There are a few situations where Zedux batches updates automatically. Some of this batching you can't control - it's an integral part of Zedux's graph algorithm. But there is one place where you can control it:
injectCallback
Zedux wraps all calls to an injected callback in ecosystem.batch()
calls.
const automaticBatchingAtom = atom('automaticBatching', () => {
const store = injectStore(0)
const updateTwice = injectCallback(() => {
store.setState(state => state + 1)
store.setState(state => state + 1)
}, [])
return api(store).setExports({ updateTwice })
})
const manualBatchingAtom = atom('manualBatching', () => {
const { ecosystem } = injectAtomGetters()
const store = injectStore(0)
// use `injectMemo` to "turn off" automatic batching:
const updateTwice = injectMemo(
() => () => {
ecosystem.batch(() => {
store.setState(state => state + 1)
store.setState(state => state + 1)
})
},
[]
)
return api(store).setExports({ updateTwice })
})
Both of these atoms behave exactly the same. You typically shouldn't need to worry about this, but if you don't want to batch updates, you can usually ditch injectors altogether and use an inline function - no need for injectMemo()
:
const noBatchingAtom = atom('noBatching', () => {
const { ecosystem } = injectAtomGetters()
const store = injectStore(0)
// create an inline function:
const updateTwice = () => {
store.setState(state => state + 1)
store.setState(state => state + 1)
}
return api(store).setExports({ updateTwice })
})
As long as the callback doesn't reference anything unstable (the store
reference is stable in this example), inline callbacks are fine in Zedux.
Why Batch?
Since Zedux only deals with local state, batching updates in Zedux doesn't improve performance as much as you might think. Batching is much more important in UI or fetching libraries where network speed and I/O are bottlenecks.
Allowing an atom to reevaluate an extra time is usually not a big deal performance-wise. So why would you ever care to use injectCallback()
or call ecosystem.batch()
?
Turns out, there are rare situations where not batching can lead to state "tearing" bugs. Since Zedux flushes all updates synchronously by default, you may encounter situations where setting state in one place leads to an atom reevaluating before you have a chance to update another piece of state that you're expecting to be in sync with the first piece.
Oof, too many words? Here's an example:
This example causes switcherAtom
to evaluate twice every time switchAndIncrement
is called. Try logging an injectWhy()
during evaluation and you'll see this happening:
The first evaluation is caused by switcherAtom
's internal store updating. However, countersAtom
's state hasn't changed yet, so switcherAtom
returns the current value of the new counter. This schedules updates in all switcherAtom
's dependents - namely the Switcher
component in this example.
The second evaluation is caused by countersAtom
updating. Now switcherAtom
sees the new value and updates again, scheduling updates in all its dependents again with the correct value this time.
As you can see, there is no real bug in this example. Well we did say they were rare! Eventually, things usually turn out right. However, if we were to give switcherAtom
a side effect that relies on things happening in a specific order, we might start to see this degrade.
Now if you click the button, you can see that history is getting tracked, but it's capturing the wrong value.
In this simple case, it's easy to update the logic to also check against value
changing, but the complexity grows (linearly) the more fields there are to check. That's a brittle approach.
The better solution is to use batching to make sure the state you want to update is fully updated everywhere before any atoms evaluate.
Simply wrapping switchAndIncrement
in injectCallback()
"turns on" automatic batching, fixing everything (try it in the above sandbox!).
Recap
- Use
ecosystem.batch()
to batch a specific set of updates. - Set an action's
meta
property tozeduxTypes.batch
to batch open-endedly. - Use
injectCallback()
to automatically batch updates. - Batching can prevent state tearing bugs. But you typically won't need to worry about it, especially if you structure side effects well.