At Cerebro, our goal is to give our users as many tools as possible for accessing data. The types of users we target vary, from the technical IT administrator, to the data analyst, to the curious developer, or even the less technical user who wants to access data to be able to visualize it in a tool of their choice.

These requirements immediately evoke the need for varying types of interfaces to this data. The two we will highlight in this article are the Cerebro Web UI and the Cerebro REST API.

The Cerebro Web UI is tailored to less technical users or technical users that want fast, easy access to a dead-simple, usable interface. The Cerebro REST API is for users that want to take it a step further to either script their own data access logic or gain full access to features not yet exposed in the UI.

Modularity is key for technologies to not only work well together, but also to be developed independently, to be properly assured of quality, to enforce useful APIs, and possibly to be shipped independently.

Thus, we instituted an internal development requirement that the Cerebro Web UI make direct use of the Cerebro REST API, for all the reasons stated above.

This requirement means we, the Cerebro Web Team, needed a modern solution to interfacing web technologies with a REST API.

Background Knowledge

In the rest of this article, I’ll gloss over some important details and assume you have some prerequisite knowledge. If you’re not already familiar, make sure you have at least a basic understanding of the following concepts:

  • React https://reactjs.org/
  • React Containers vs. Pure Components https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0
  • Redux https://redux.js.org/
  • Redux-Thunk https://github.com/gaearon/redux-thunk
  • Modular Redux https://github.com/erikras/ducks-modular-redux
  • Basic REST APIs
  • ES6 Promises and async/await keywords

Redux: modern state management

The Cerebro Web UI is a technology developed in React, following modern guidelines for creating unidirectional data flow for view rendering. A natural counterpart to React is Redux, which is what we use for client-side state storage.

Since our backend is a REST API, Redux became our client-side cache of data retrieved from the REST API.

We found lacking guidance in the developer community for exactly how to implement asynchronous Redux actions in a reusable way for codebases that require multiple types of REST calls to be stored in the Redux state tree, with common states: fetching, error, and data loaded. Our first approach rapidly morphed into a ton of boilerplate code, with our Redux actions and reducers duplicating a lot of similar code, leading to poor unit testability, code maintainability, reuse, and ease of development.

A Proposal for Reusable Redux Modules for REST APIs

We needed a solution that allowed us to create React views from data driven by our REST API. Here’s what we came up with.

In the following, assume we have two REST APIs we want to connect to our Redux state store:

  • GET /api/users/. This endpoint returns data about a user.
  • GET /api/posts?owner=. This endpoint returns a list of blog posts owned by a user.

Have a service layer

First and foremost, create a module that allows access to the REST API, regardless of how it’s used. This can ideally be used outside of Redux, and can be used to make other facilities like authentication caching easier.

Example:

// File: service/api.js

export function getUser(id) {
  return fetch(`/api/users/${id}`);
}

export function getPosts(userId) {
  return fetch(`/api/posts?owner=${userId}`);
}

The above code is very basic, but typically will include other functions like parsing and error handling.

Create Redux modules for each endpoint

Following the approach proposed in https://github.com/erikras/ducks-modular-redux, our solution involves keeping actions and reducers for a single endpoint in a single file.
The crux of our solution involves a special module, the fetch module. It is the generic module we use to ensure we can create new modules quickly.

// File: modules/fetch.js

// Actions
export const REQUEST = 'modules/fetch/REQUEST';
export const SUCCESS = 'modules/fetch/SUCCESS';
export const ERROR = 'modules/fetch/ERROR';

// Reducer
export default function reducer(state, action={}) {
  const { storePath, data } = action;

  const fetchState = {
    fetching: false,
    error: false,
    data: null,
  };

  switch (action.type) {
    case REQUEST:
      fetchState.fetching = true;
      fetchState.error = false;
    case SUCCESS:
      fetchState.fetching = false;
      fetchState.error = false;
      fetchState.data = data;
    case ERROR:
      fetchState.fetching = false;
      fetchState.error = true;
 }

 // Replace the current state at `storePath` with the new
 // computed `fetchState`.
 return {
    ...state,
    [storePath]: {
      ...state[storePath],
      ...fetchState,
    }
  };
}

// Action creators

// Create a fetch action flow.
// * storePath: JSON key for location in the store, e.g. 'user'.
// * api: function that makes a REST call, return a promise
// * apiArgs: list of args to send to `api`
export function createFetch(storePath, api, apiArgs) {
  // Use Thunk middleware to dispatch asynchronously
  return async (dispatch) => {
    dispatch({ type: REQUEST, storePath });
    
    try {
      const data = await api(...apiArgs);
      dispatch({ type: SUCCESS, storePath, data });
    } catch (e) {
      dispatch({ type: ERROR, storePath });
    }
  };
}

Now we can implement as many new fetch modules on this pattern as we want.

Let’s start with the users module.

// File: modules/user.js

import { getUser } from 'service/api';
import { createFetch } from 'modules/fetch.js';

const STORE_PATH = 'user';

// Action creators
export function load(id) {
  return createFetch(STORE_PATH, getUser, id);
}

// Selectors
export function selectUser(state) {
  return state[STORE_PATH].data;
}
export function selectFetching(state) {
  return state[STORE_PATH].fetching;
}
export function selectError(state) {
  return state[STORE_PATH].error;
}

export function selectUsername(state) {
  return selectUser(state).username;
}

This same pattern can be easily extended to the posts module.

// File: modules/user.js

import { getPosts } from 'service/api';
import { createFetch } from 'modules/fetch.js';

const STORE_PATH = 'posts';

// Action creators
export function load(userId) {
  return createFetch(STORE_PATH, getPosts, userId);
}

// Selectors
export function selectPosts(state) {
  return state[STORE_PATH].data;
}
export function selectFetching(state) {
  return state[STORE_PATH].fetching;
}
export function selectError(state) {
  return state[STORE_PATH].error;
}

export function selectFirstPost(state) {
  return posts.length > 0 ? posts[0] : null;
}

In this way, we can eliminate a significant amount of boilerplate code for initiating and managing fetches that should be written to the Redux store.

Basic Integration with React

We make heavy use of the React container/component pattern, see https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0.

With our Redux modules implement as above, we can have clean integrations with React containers, like this:

// File: containers/UserPosts.js
import React, { Component } from 'react';
import { connect } from 'react-redux';

// 1. Import action creators and selectors from our modules
import {
load as loadUser,
selectFetching as selectUserLoading,
selectError as selectUserError,
selectUsername,
} from 'modules/user.js';

import {
load as loadPosts,
selectFetching as selectPostsLoading,
selectError as selectPostsError,
selectPosts,
} from 'modules/posts.js';

// 2. Use selectors to define username/loading/error props
const mapStateToProps = (state) => ({
username: selectUsername(state),
loading: selectUserLoading(state) || selectPostsLoading(state),
error: selectUserError(state) || selectPostsError(state),
});

// 3. Use action creators to define how to load.
// In this case, we want to load the `user` and the `posts`.
const mapDispatchToProps = (dispatch) => ({
load: (userId) => {
loadUser(userId);
loadPosts(userId);
}
});

// Note: we are hard-coding the user ID, for code simplicity.
const TEST_USER_ID = '1234';

// 4. Use our loader and data in a pure React component
class UserPosts extends Component {
render() {
const content = this.renderContent();

return (
<div><button> this.props.load(TEST_USER_ID)}>
Load posts!
</button>
<gt;
{content}</div>
);
}

renderContent() {
if (this.props.loading) {
return 'Loading...';
}

if (this.props.error) {
return 'There was an error :(';
}

return (
<div>
<h2>Hello {this.props.username}</h2>
These are your posts:
<ol>
 	<li style="list-style-type: none;">
<ol>{this.props.posts.map((post) => (
 	<li>{post.name}</li>
</ol>
</li>
</ol>
))}

</div>
);
}
}

export default connect(mapStateToProps, mapDispatchToProps)(UserPosts);

The key notes from above:

  1. In our React component file, we are importing the action creators and selectors from the Redux modules we defined above.
  2. We map the selectors, which encapsulate our fetching, error, and data states to values on the React component’s props.
  3. We map the action creators to a generic load function on the React component, that concisely fetches both the user and the posts.
  4. Finally, we use the loading, error, and username props to render the proper visual state for the React component, as well as use the load function to manage fetching new data.

We can use this pattern to easily create thin React containers that map props into pure components, as seen in the code snippet. Just as easily we can add or remove data or loaders for new REST endpoints that we may wish to show. Imagine how straightforward it would be to display information from a new REST endpoint, e.g. one that shows a list of the user’s friends, by creating a new Redux module and importing it in the React view.

Finally, reusing these modules in other React containers is a simple matter of importing them!

Looking Ahead

The solution shown here is an example of how we approach the Redux/REST problem, but it is by no means complete. In our product code, we have additional logic dealing with authentication, more expressive ways to indicate the state’s location in the store, and logic to bootstrap the Redux store so it handles these actions/reducers properly.

We feel strongly that this general approach is extremely useful to build a strong codebase for Redux that can be continually refactored and enhanced to make an overall awesome product.