Skip to main content

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.

you will learn:
  • 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:

Live Sandbox
12345678910111213141516171819202122232425262728
const countersAtom = atom('counters', () => ({ even: 0, odd: 1 }))

const switcherAtom = ion('switcher', ({ get, getInstance }) => {
// this store determines which counter we subscribe to:
const internalStore = injectStore('odd')
const key = internalStore.getState()
const countersInstance = getInstance(countersAtom)
const value = get(countersInstance)[key]

return api(value).setExports({
switchAndIncrement: () => {
const newKey = internalStore.getState() === 'even' ? 'odd' : 'even'
internalStore.setState(newKey)
countersInstance.setStateDeep(state => ({ [newKey]: state[newKey] + 2 }))
},
})
})

function Switcher() {
const [count, { switchAndIncrement }] = useAtomState(switcherAtom)

return (
<>
<div>Count: {count}</div>
<button onClick={switchAndIncrement}>Switch and increment</button>
</>
)
}

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.

Live Sandbox
1234567891011121314151617181920212223242526272829303132333435363738
const countersAtom = atom('counters', () => ({ even: 0, odd: 1 }))

const switcherAtom = ion('switcher', ({ get, getInstance }) => {
const internalStore = injectStore('odd')
const key = internalStore.getState()
const countersInstance = getInstance(countersAtom)
const value = get(countersInstance)[key]
const history = injectRef([{ key, value }])

// track update history whenever key changes
if (key !== history.current[history.current.length - 1].key) {
history.current.push({ key, value })
}

return api(value).setExports({
history,
// wrapping this function in `injectCallback()` fixes everything:
switchAndIncrement: () => {
const newKey = internalStore.getState() === 'even' ? 'odd' : 'even'
internalStore.setState(newKey)
countersInstance.setStateDeep(state => ({ [newKey]: state[newKey] + 2 }))
},
})
})

function Switcher() {
const switcherInstance = useAtomInstance(switcherAtom)
const { history, switchAndIncrement } = switcherInstance.exports
const count = useAtomValue(switcherInstance)

return (
<>
<div>Count: {count}</div>
<button onClick={switchAndIncrement}>Switch and increment</button>
<pre>{JSON.stringify(history.current, null, 2)}</pre>
</>
)
}

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 to zeduxTypes.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.