Concurrent async producers, an experiment with ImmerJS
If there's one thing Redux-like libraries don't reduce, it's the amount of boilerplate code you need to write before your app does anything useful. And most developers don't like writing boilerplate as evidenced by the surge in popularity of ImmerJS, which automates the process of writing reducers.
What if we could go one step further, and automate the effects + actions + reducer paradigm ?
The problem with async producers
Unfortunately, the following doesn't work with Immer:
The output state will either be
These async reducers will end up overwriting each other if they are run concurrently. The new state will contain either user
or articles
but not both, depending on whichever producer completes last. Because of this, it's usually not a good idea to write async producers.
Concurrent producers
With concurrent producers, async actions share the same draft when they run concurrently.
Output state will correctly be reduced to:
These producers are comparable to composing actions, effects, and pure reducers with libraries like Thunk / Saga / NGRX, all in one (impure) action.
- Synchronous portions of the async producer represent your pure reducers
- They can be interleaved with side effects e.g. http calls
- State updates are batched, the latest state is emitted once an async producer terminates
- This means updates done in a synchronous section might be sent before its scope completes.
- They should leave the store in a consistent state (just like you'd expect of any reducer).
Concurrent producer factory
Stackblitz: https://stackblitz.com/edit/immer-concurrent-producer-last-hvh3xn?file=index.ts
Considerations
Limitations include:
- State updates must be done through the Immer proxy. It's not possible to return an entirely new state as there might be other pending tasks relying on the current draft.
- This is a performance trade-off, since only the last async producer in the current stack will finalize the draft. Other actions will emit the current state represented in the draft, which may be a less efficient operation than finalizing the draft.
- Draft will accumulate changes until the queue is empty, so it is not a good solution if async actions are running continuously
- Objects produced through
current
may not be freezed. - It's not clear how safe it is to share the draft, potential errors could invalidate all current async actions
The overhead of always going through Immer proxy and using current()
is mitigated by the fact that state updates are now batched. Concurrent actions will only produce the new state once they complete. With Thunk / Saga / NGRX etc. the store is continuously updated which can lead to unnecessary re-render.
Going forward
Ideally you would want a function to finalize the current draft without revoking the proxy. This would essentially create a commit point and restart the draft from the current state, instead of accumulating the mutations in the same draft until all tasks complete.
This could be implemented using a concurrent proxy wrapper, but perhaps a lower level Immer API is preferable, something like state = commitDraft(draf)
.
bd2f6a3894