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.

We were unable to confirm your registration.
Your registration is confirmed.

Keep up to date

Join our react-admin newsletter for regular updates. No spam ever.

Did you like this article? Share it!