The marmelab blog

Using Saga To Accumulate And Deduplicate Redux Actions

Published on 18 October 2016 by François Zaninotto with tags react admin-on-rest 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.

comments powered by Disqus