Using Saga To Accumulate And Deduplicate Redux Actions

François Zaninotto
François ZaninottoOctober 18, 2016
#react#admin-on-rest#react-admin#tutorial#rest

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.

Did you like this article? Share it!