Sometimes people ask what is the best way to handle asynchronicity in Redux? There is official documentation about it, but I suggest revisiting some basic concepts to see if it's really that simple.
The basics
A state
is an object. It's used as a value somewhere on UI or for its rendering:
{
username: "zerocool"
}
An action
is an object too. It describes an event (or a command) happened in app's world. By convention it must have the "type" property containing event name and may have some other data:
{
type: "ADD_TODO",
text: "Hello"
}
A reducer
is a function. Its signature is
(state, action) => state
The following example has a function with similar signature and even a comparable method name "reduce":
[1, 2, 3].reduce((acc, item) => acc + item, 0)
In fact, this is exactly what happens in Redux, but instead of an array of numbers Redux gets an infinite array (stream) of events (actions), and its reduction spans the life-time of the app. Of course, state
and action
could be primitive types in Redux too, but in real world apps it isn't super useful.
A reducer
is all about computation. Nothing more, nothing less. It is synchronous, pure, and simple like a sum.
Developers use Redux through a store
. It is an object that remembers the computation (reducer) and its first argument (state) freeing you from passing it every time. Interactions are based on calling dispatch()
method to run the computation and accessing the last computed value by calling getState()
. Parameter types are irrelevant to dispatch()
because it simply passes them to reducer, dispatch()
doesn't return a value either. This is how a simple Redux store may look and work like:
// Instead of manually implementing store subscriptions we could use EventEmitter.
class Store extends EventEmitter {
constructor(fn, value) {
super();
this.$fn = fn;
this.$value = value;
}
getState() {
return this.$value;
}
dispatch(data) {
// This is the only thing happening inside a store.
this.$value = this.$fn(this.$value, data);
this.emit("change");
}
}
// Let's try the store on numbers.
const store1 = new Store((acc, item) => acc + item, 0);
// And output its state to the console on every dispatch.
// "on()" is similar to "subscribe()" in the Redux and comes from EventEmitter.
store1.on("change", () => console.log(store1.getState()));
[1, 2, 3].forEach(item => store1.dispatch(item));
// 1
// 3
// 6
// Now let's try a more real-world reducer.
const store2 = new Store((state, action) => {
switch (action.type) {
case "ADD_ITEM":
return { ...state, items: [...(state.items || []), action.item] };
default:
return state;
}
}, {});
// Outputting the state as a JSON.
store2.on("change", () => console.log(JSON.stringify(store2.getState())));
store2.dispatch({ type: "ADD_ITEM", item: "Hello" });
// {"items":["Hello"]}
store2.dispatch({ type: "ADD_ITEM", item: "World" });
// {"items":["Hello","World"]}
It looks KISSish and complies with the Single responsibility principle. The example is so simple that it's hard to imagine where to put asynchronicity into. As you will see later, attempts to add asynchronicity will break some of the definitions written above.
By the way, the original Redux isn't that small. Why? Because it provides various utilities: middlewares, store enhancement, etc. More on this later.
Asynchronicity
If you try to read Redux docs about asynchronicity, the first page you'll encounter is the Async Actions page. Its title looks rather strange because we know that actions are objects and objects can't be async. Reading further down you see Async Action Creators and middlewares for them.
Let's look at what are regular synchronous Action Creators first. From the docs:
Action creators are exactly that - functions that create actions.
function addTodo(text) {
return {
type: "ADD_TODO",
text
}
}
dispatch(addTodo("Finish the article"));
A factory function for reducing code duplication in creating action objects, cool. If there're dispatches of same actions in different parts of the app, Action Creators may help.
Middlewares. They are utilities to override store's behavior in more functional style (like Decorators in OOP). So, you don't have to write this by hand if you want to log every dispatched action to the console:
const originalDispatch = store.dispatch;
store.dispatch = function myCustomDispatch(action) {
console.log(`action : ${action.type}`);
originalDispatch.call(this, action);
};
In reality it looks more like a chain of dispatch functions calling each other in order with the original one in the end. But the idea is similar. Async Action Creators require specific middlewares to work, let's check them out.
Redux Thunk
The first one on the list is redux-thunk. This is how a thunk may look like:
function addTodo(text) {
return dispatch => {
callWebApi(text)
.then(() => dispatch({ type: "ADD_TODO", text }))
.then(() => sendEmail(text));
};
}
dispatch(addTodo("Finish the article"));
From the description of the library:
Redux Thunk middleware allows you to write action creators that return a function instead of an action.
Returning a function from Action Creators? Actions Creators create actions (objects), it's obvious from their name. There should be a new term instead.
Google says that by returning functions you may continue to dispatch normally and components will not depend on Action Creators' implementation. But dispatching "normally" means running the computation of the new state and doing it synchronously. With this new "normal" dispatch you can't check getState()
to see the changes right after the call, so the behavior is different. It's like patching Lodash.flatten()
to allow you to continue "normally" flattening Promises instead of Arrays. Action Creators return objects, so there's no implementation either. Same time, presentational components don't usually know about dispatch()
, they operate with available handlers (passed as React props). Buttons are generic. It's Todo page who decides what a button does, and this decision is specified by passing the right onClick
handler.
The thunk can be used to delay the dispatch of an action, or to dispatch only if a certain condition is met.
A dispatch()
is a function call, just like sum()
. How to delay sum()
in JavaScript? By using setTimeout()
. How to delay a button click? With setTimeout()
, but inside a handler. It is unlikely that patching a button to know how to delay clicks (if it is not a button animating delay countdown, which is different) is necessary. How to call a function if certain conditions are met? By adding an "if-then-else" block inside a handler. Plain JS.
Looking closer at the proposed dispatch call itself. Not only it changes dispatch's interface:
dispatch(dispatch => { … });
But we're passing a function expecting dispatch as an argument into a function called dispatch. This is quite confusing 🤷♂️ Melding together different concepts removes simplicity and raises contradictions. But what is the problem that Redux Thunk is trying to solve in the first place?
function handleAddTodo() {
dispatch(addTodo(text));
}
<Button onClick={handleAddTodo}>Add Todo</Button>
Adding some async calls turns into:
function handleAddTodo() {
callWebApi(text)
.then(() => dispatch(addTodo(text)));
}
<Button onClick={handleAddTodo}>Add Todo</Button>
Nothing has changed for the button, but there is a problem indeed if you have several identical handleAddTodo()
implementations in different parts of the app. Cutting corners with Redux Thunk may look like a solution, but still will add all downsides this middleware introduce. It can be avoided by having only one implementation somewhere on upper level and passing it down or by extracting dispatch()
calls into external functions (basically moving handleAddTodo()
to another file).
Redux Promise
Redux Promise encourages you to dispatch Promises. It is very similar by effect to Redux Thunk, so I'll skip it.
There is also another way encouraged by subsequent middlewares, but let's step aside from thunks and asynchronicity for a second and talk about processes happening inside apps.
Business Logic
Apps react on users and environment. Complexity of reactions grows with app's complexity. Instead of simple things like changing button's color on a click, apps starts to execute rather complex scenarios. For example, adding a Todo record to the state is simple. Adding it also to the local storage, syncing it to a backend, showing a notification on the screen… is not so. Somewhere between those steps may be even a user interaction.
Such groups of actions are usually represented by flow charts and have many names: flows, workflows, control flows, business processes, pipelines, scenarios, sagas, epics, etc. I will use the term "workflow". A simple money transfer between two bank accounts internally may be a huge operation involving distributed transactions between multiple independent parties. But the workflow from the image above may be a simple function:
function addTodoWorkflow(text) {
dispatch(addTodo(text));
saveToLocalStorage(text);
if (isSignedIn) {
const response = syncWithServer(text);
if (response.code === OK) {
showSuccess();
dispatch(todoSynced());
} else {
showError();
}
}
}
It looks like and totally is a regular function composition. I made it sync, but it will be the same with promises.
From the point of workflow's view dispatch(), syncWithServer(), and Lodash.groupBy() are the same.
Browser APIs, web clients, libraries, triggering UI changes, coming from imports or arriving in arguments, sync or async. They all are just some services that were composed into a workflow to do the job. Even if a workflow is asynchronous, you still run it like this:
addTodoWorkflow(args...);
If you have a button submitting a Todo, just call it in the event handler. In more advanced scenarios you will have tons of async stuff, cancellation, progress reporting, etc. Achieving this is possible with extended promises, generators, streams, and other libraries and techniques (such as reactive programming).
Workflows exist is many areas of software development, and they aren't tied to UI state management. They may also call dispatch() several times with completely different action types or not to have UI indication and state change at all. Workflows may be composable just like functions in JS. Similar concepts exist even high in the clouds and in IoT.
Understanding that workflows are a separate concern is important. By moving business logic into Action Creators this separation starts to vanish. Redux doesn't require special treatment, nor it is more important than other subsystems in the app.
There two ways of executing workflows: directly and indirectly.
The direct way is the simplest: you call the workflow directly in a handler. This way you have a good visibility of what will happen and control right in the code:
function onAddTodoClick() {
addTodoWorkflow(text);
}
The indirect way is opposite. You start with a dummy action like ADD_TODO
that must not change any state, but there is another system subscribed to Redux actions. This system will launch a workflow defined for this specific action. This way you may add functionality without updating UI components' code. But now you have no idea what will happen after a dispatch. Let's look on the middlewares.
Redux Saga
Redux Saga isn't really about the Saga pattern.
The Distributed Saga pattern is a pattern for managing failures, where each action has a compensating action for rollback.
It doesn't help you dealing with state rollbacks. Instead it allows you to write workflows in a CSP-style manner, but with power of generators (which is great). There're very few mentions of Redux in the docs. 99% of Redux Saga are about sagas themselves hidden in sub-packages.
Sagas are pure workflows, and the docs teach you to manage running tasks, doing effects, and handling errors. The Redux part only defines a middleware which will repost actions to the root saga. Instead of manually building a map [Action → Saga]
you need to compose all sagas into a tree similar to reducers composition in Redux. UI code remains the same:
function addTodo(text) {
return {
type: "ADD_TODO",
text
}
}
function handleAddTodo() {
dispatch(addTodo(text));
}
<Button onClick={handleAddTodo}>Add Todo</Button>
Changes happen only in the corresponding saga:
function* addTodoSaga(action) {
yield takeEvery("ADD_TODO", function* (action) {
const user = yield call(webApi, action.text);
yield put({ type: "ADD_TODO_SUCCEEDED" });
});
}
function* rootSaga() {
yield all([
...,
addTodoSaga()
]);
}
It is dramatically different to Redux Thunk: the dispatch()
hasn't changed, Action Creators stay sync and sane, Redux continues to be simple and clear.
Redux Observable
Redux Observable is identical to Redux Sagas, but instead of CSP and Sagas you work with Observables and Epics leveraging RxJS (more difficult, but even more powerful).
Retrospective
So, what is the best way to handle asynchronicity in Redux?
There is no asynchronicity in Redux. You should not build a facade with middlewares like Thunk hiding the real Redux behind it. It couples the knowledge of workflow execution with UI state management and makes the terminology complicated.
There are ways to react on actions in a better manner. You may choose a direct approach of calling workflows manually and/or going by indirect path of binding workflows to actions. Both ways have their own strengths and weaknesses.
Sagas provide a nice balance in ease of use, functionality, testability and may be a good starting point. Same time, choosing Sagas over calling workflows directly is like choosing between Redux and React State: you don't always need the former.
In advanced scenarios with async modules you may want to register new sagas/epics on demand instead of a prebuilt root saga/epic. But usually it's better not to overthink.
Originally posted on Medium in 2019.
Top comments (0)