Using Saga To Accumulate And Deduplicate Redux Actions
While developing the <ReferenceField>
component for admin-on-rest, I recently stumbled upon a fun use case that demonstrates the power of redux-saga. I needed to accumulate and deduplicate actions for performance reasons. Saga proved to be perfect for that job! Read on to see if Saga can help you, too.
The <ReferenceField>
Component
We recently released Admin-on-rest, a frontend framework for building admin GUIs on top of REST APIS, based on React.js. One of the components of admin-on-rest is called <ReferenceField>
, and it's perfect to fetch references based on a foreign key.
Let me give you an example. Let's say you have a REST endpoint that returns a list of posts. Each post references an author, through an author_id
attribute:
GET /posts
HTTP 1.1 OK
[
{
id: 93,
title: 'seatae soluta recusante',
author_id: 789,
},
{
id: 124,
title: 'commodi ulam sint et',
author_id: 456
},
{
id: 125,
title: 'consequatur id enim sint',
author_id: 735
},
...
]
If you want to display this list with admin-on-rest, all you have to do is write the following code:
// in src/posts.js
import React from "react";
import {
List,
Datagrid,
TextField,
ReferenceField,
} from "admin-on-rest/lib/mui";
export const PostList = props => (
<List {...props}>
<Datagrid>
<TextField source="id" />
<ReferenceField label="User" source="author_id" reference="users">
<TextField source="name" />
</ReferenceField>
<TextField source="title" />
<TextField source="body" />
</Datagrid>
</List>
);
The <ReferenceField>
component receives the current record
for each line in the datagrid. It then fetches the /users/:id
API endpoint for the author_id
of each post. It's very simple:
export class ReferenceField extends Component {
componentDidMount() {
const { reference, record, source } = this.props;
dispatch({
type: "CRUD_GET_ONE_REFERENCE",
payload: { resource: reference, id: record[source] },
});
}
componentWillReceiveProps(nextProps) {
if (this.props.record.id !== nextProps.record.id) {
const { reference, record, source } = nextProps;
dispatch({
type: "CRUD_GET_ONE_REFERENCE",
payload: { resource: reference, id: record[source] },
});
}
}
render() {
const { reference, referenceRecord, children } = this.props;
if (!referenceRecord && !allowEmpty) {
return <LinearProgress />;
}
return React.cloneElement(children, {
record: referenceRecord,
resource: reference,
});
}
}
The referenceRecord
is mapped by Redux from the state. It is empty until the reference is fetched.
Saving API Calls
The naive implementation of the CRUD_GET_ONE_REFERENCE
action handler works like a DDoS attack: for each line in the datagrid, the component fetches the /users
endpoint once for the related user.
GET /users/789
GET /users/456
GET /users/735
...
This is not effective at all, especially since most REST APIs accept a filter
parameter, allowing to group these n calls into a single one:
GET /users?filter={ids:[789,456,735,...]}
Besides, if two posts are authored by the same user, the naive approach fetches the same user twice.
When a React component dispatches actions, how can another service catch the actions, accumulate and group them, then redispatch a single action?
Enter Saga
Redux-saga is a side effect library for Redux. "Side effect" here means everything Redux reducers cannot do by themselves: redispatching another action, relying on an external datasource (like an API or setTimeout
), etc. Saga uses ES5 generators to offer unique features and the <ReferenceField>
use case is a great example.
From a Redux app's point of view, Saga is like a middleware. Saga will see every dispatched action. It can choose to catch one, and redispatch another one after a while. That's exactly what we'll do:
import { delay, takeEvery } from "redux-saga";
import { call, cancel, fork, put, take } from "redux-saga/effects";
/**
* Example
*
* let id = {
* posts: { 4: true, 7: true, 345: true },
* authors: { 23: true, 47: true, 78: true },
* }
*/
const ids = {};
const tasks = {};
// see http://yelouafi.github.io/redux-saga/docs/recipes/index.html#debouncing
function* fetchReference(resource) {
// combined with cancel(), this debounces the calls
yield call(delay, 50);
yield put({
type: "CRUD_GET_MANY",
payload: { resource, ids: Object.keys(ids[resource]) },
});
delete ids[resource];
delete tasks[resource];
}
function* accumulate({ payload }) {
const { id, resource } = payload;
if (!ids[resource]) {
ids[resource] = {};
}
ids[resource][id] = true; // fast UNIQUE
if (tasks[resource]) {
yield cancel(tasks[resource]);
}
tasks[resource] = yield fork(fetchReference, resource);
}
export default function*() {
yield takeEvery("CRUD_GET_ONE_REFERENCE", accumulate);
}
There is a lot to explain here, and it reads from the bottom up.
The default function registers a new watcher called accumulate()
, which will be called each time a CRUD_GET_ONE_REFERENCE
is dispatched (that's the action dispatched by the <ReferenceField>
component).
This accumulate()
function adds the id
from the payload to an ids
object that's in the middleware closure, with a trick to deduplicate ids. Then, accumulate()
forks another handler called fetchReference()
, and keeps a reference in tasks
.
This third function, fetchReference()
, starts by a delay()
of 50ms (equivalent of a setTimeout()
). If another CRUD_GET_ONE_REFERENCE
action is caught before the timeout ends, then the initial fetchReference()
fork is cancelled. The net effect is a debounce: fetchReference()
will only pass the call to delay
the last time a CRUD_GET_ONE_REFERENCE
is dispatched.
call
, cancel
, fork
, put
, and take
are high-level side effect helpers provided by saga. Check out the saga documentation to learn more about these side effects.
Thanks to the saga we wrote, n calls to CRUD_GET_ONE_REFERENCE
will result in a single CRUD_GET_MANY
action dispatch:
{
type: 'CRUD_GET_MANY',
payload: {
resource: 'users',
ids: [789,456,735,...],
}
}
This action is then caught by another middleware (powered by Saga too, but you could use the thunk middleware or a promise middleware) to issue an HTTP call to:
GET /users?filter={ids:[789,456,735,...]}
The frontend now displays the list in a snap!
Conclusion
Redux Saga allowed us to keep the component logic simple (each <ReferenceField>
component dispatches one single action), and to add sophisticated side effects in the controller part of the application. Even if the initial learning curve is not easy, after a day or two, you'll understand the huge potential that Saga offers. We definitely recommend it for complex React apps with performance requirements.