A modern JS app (V): building our frontend with React, Redux, Saga, and Apollo

A modern React / Node.js application

Part 5: Building our frontend with React, Redux, and Redux-Saga

React ecosystem

Currently our frontend is pretty sparse. We’ve used create-react-app to bootstrap our UI with React, and that’s about it. Now, we’ll install and use a few libraries to take care of stuff like state management, data-flow, routing, and app behavior.

Note, we’re actually not going to use react-apollo, the React bindings for the Apollo framework. The reason is that I prefer to keep my React views very thin: they shouldn’t care where their data came from, they should only be concerned with displaying it. Still, check out the bindings and how they work, because they’re very interesting way to fetch/mutate data, and you may actually prefer a “fatter” view.

Facebook’s Relay is a similar library. It is perhaps a bit more powerful but the learning curve is steeper. There are many articles online that compare the two. You can’t really go wrong with either, I simply chose Apollo for this post due to its lower barrier of entry.

Configure our Redux store

Our app integrates a few different Redux plugins, so our configuration will be pretty hairy. Here is the new src/index.js:

import React from 'react'
import ReactDOM from 'react-dom'
import App from './Components/App/App'
import registerServiceWorker from './registerServiceWorker'
import { createStore, applyMiddleware, combineReducers, compose } from 'redux'
import { Provider } from 'react-redux'
import createSagaMiddleware from 'redux-saga'
import entitiesReducer from './Reducers/Entities'
import rootSaga from './Sagas/RootSaga'
import './index.css'
import { apolloClient } from './Api/ApolloProxy'
import { routerForBrowser, initializeCurrentLocation } from 'redux-little-router';
import routes from './Routes'

// initialize our router
const {
  reducer     : routerReducer,
  middleware  : routerMiddleware,
  enhancer    : routerEnhancer
} = routerForBrowser({ routes })

// build our store
const sagaMiddleware = createSagaMiddleware()
const store = createStore(
  combineReducers({
    app     : entitiesReducer,
    router  : routerReducer,
    apollo  : apolloClient.reducer()
  }),
  {}, // initial state
  compose(
    routerEnhancer,
    applyMiddleware(sagaMiddleware, routerMiddleware, apolloClient.middleware()),
    (typeof window.__REDUX_DEVTOOLS_EXTENSION__ !== 'undefined') ? window.__REDUX_DEVTOOLS_EXTENSION__() : f => f,
  )
);

// kick off rootSaga
sagaMiddleware.run(rootSaga)

// render app
ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById('root')
)
registerServiceWorker()

// hot-reloading
if (module.hot) {
  module.hot.accept('./Components/App/App', () => {
    const NextApp = require('./Components/App/App').default
    ReactDOM.render(
      <Provider store={store}>
        <NextApp />
      </Provider>,
      document.getElementById('root')
    )
  })
}

// bootstrap the router with initial location
const initialLocation = store.getState().router;
if (initialLocation) {
  store.dispatch(initializeCurrentLocation(initialLocation));
}

This is much different from our original index.js. Let’s go over the changes:

Routes

Let’s define the routes we will expose:

The last 3 routes will require a user to login to access them.

Let’s create our src/Routes.js file:

import homeSaga from './Sagas/RouteSagas/HomeSaga'
import propertyDetailsSaga from './Sagas/RouteSagas/PropertyDetailsSaga'

const routes = {
  '/': {
    title: 'Properties',
    saga: homeSaga
  },
  '/property/:id' : {
    title: 'Property Details',
    saga: propertyDetailsSaga
  },
  '/room/:id': {
    title: 'Room details'
  },
  '/manage': {
    title: 'Manage Properties',
    '/:id': {
      title: 'Manage Property'
    },
    '/room/:id': {
      title: 'Manage Room'
    }
  }
}

export default routes

Here we’re importing two sagas: the homeSaga and propertyDetailsSaga. We’ll import more, but for now we’ll concentrate on the / and /property/:id routes.

The exported object is a simple map from the route to an object that is attached to the ROUTER_LOCATION_CHANGED action fired by redux-little-router on a route change.

There’s nothing special about the keys (e.g., title, saga) inside each object, save for the ones that start with /, as they signify a nested route. The /manage route has two nested routes: /:id and /room/:id, which mean /manage/:id will be where a manager updates a Property, and /manage/room/:id will be where a manager updates a Room.

Directory structure

Let’s add a few folders to client/src. In client:

mkdir src/Reducers src/Actions src/Components src/Sagas

Every directory we created, except for Components, is Redux-specific. One critique of Redux is that it’s very boilerplate heavy. That can be true (it doesn’t have to be). It’s a tradeoff I’m willing to make: we add a little more boilerplate, but gain much more declarative, readable, and… reason-about-able code.

The Reducer

In our Reducer folder, let’s add a file called Entities.js:

import { combineReducers } from 'redux'
import { List, Map, fromJS } from 'immutable'
import { FETCH_LIMIT } from '../Constants'

const initialPropertyState = fromJS({
  selectedItem: {},
  showing: -1,
  buffer: {},
  properties: [],
  args: {},
  searchParameters: {
    sortKey: 'id',
    sortAsc: true,
    searchText: '',
    first: FETCH_LIMIT,
    skip: 0
  }
})

function Property(state = initialPropertyState, action) {
  switch(action.type) {
    case 'SHOW_MORE':
      return state.withMutations(st => {
        const showing = st.get('showing')
        const bufferedProperties = st.getIn(['buffer', showing])
        st.update('showing', s => s + 1)
        if (bufferedProperties) {
          st.update('properties', properties => properties.concat(bufferedProperties))
          st.deleteIn(['buffer', showing])
        }
      })
    case 'FETCH_ENTITIES':
      return state.withMutations(st => {
        st.update('searchParameters', searchParameters => searchParameters.merge(action.searchParameters))
          .update('args', args => args.merge(action.args))
      })
    case 'FETCH_ENTITIES_SUCCESS':
      // if the request's batchIndex (i.e., the value of showing at time of request)
      // is more than the current value for showing, then it goes into the buffer,
      // which is a temporary hold for property batches that shouldn't yet be shown
      // otherwise, we simply append the results to the current properties
      return state.get('showing') <= action.batchIndex ?
        state.update('buffer', buffer => buffer.set(action.batchIndex, List(action.entities))) :
        state.update('properties', properties => properties.concat(action.entities))
    case 'FETCH_ENTITY_DETAILS_SUCCESS':
      return state.set('selectedItem', Map(action.entity))
    default:
      return state
  }
}

export default combineReducers({
  Property
})

This file defines an initialState object that is an immutable data-structure, as we can see from the fromJS. Let’s go over the object’s properties:

Then we have our Property reducer, which takes the current state and an action, and returns the new state.

Clientside API

As mentioned above, we’ll use the apollo framework to make API requests. It allows us to do things like define re-useable GraphQL fragments, so we can declaratively specify the data that each request expects. As we saw earlier, it uses Redux under the hood, and thus we simply integrated with our own Redux store. Install it now:

npm i --save apollo-client

We’ll also install graphql-tag which allows us to represent GraphQL queries as interpolated strings:

npm i --save graphql-tag

Let’s add our initial call to retrieve a list of properties from the server. We’ll create an Api folder and it will contain two files:

src/Api/ApolloProxy.js:

import ApolloClient, { createNetworkInterface } from 'apollo-client'
import gql from 'graphql-tag'
import * as Fragments from './Fragments'

/**
  The API wraps an Apollo client, which provides query/mutation execution
  as well as fragment caching.
*/
export const apolloClient = new ApolloClient({
  networkInterface: createNetworkInterface({ uri: 'http://localhost:3000/graphql' })
})

/**
  @param {Object} args - Map of Property attribute names to values.
  @returns {Promise<Property>} Uses Apollo to fulfill fetchProperty query.
*/
export const fetchProperty = ({ id }) => apolloClient.query({
  query: gql`
    query FetchProperty($id: ID!) {
      fetchProperty(id: $id) {
        ... PropertyAttributes
        rooms {
          ... RoomAttributes
        }
      }
    }
    ${Fragments.Property.attributes}
    ${Fragments.Room.attributes}
  `,
  variables: {
    id
  }
})


/**
  @param {Object} args - Map of Property attribute names to values.
  @param {Object} search - Map of search parameters.
  @returns {Promise<List<Property>>} Uses Apollo to fulfill listProperties query.
*/
export const listProperties = (args, search) => apolloClient.query({
  query: gql`
    query ListProperties($args: PropertyInput, $search: SearchParameters) {
      listProperties(args: $args, search: $search) {
        ... PropertyAttributes
      }
    }
    ${Fragments.Property.attributes}
  `,
  variables: {
    args,
    search
  }
})

src/Api/Fragments.js:

import gql from 'graphql-tag'

export const Property = {
  attributes: gql`
    fragment PropertyAttributes on Property {
      id
      street1
      street2
      city
      state
    }
  `,
  rooms: gql`
    fragment PropertyRooms on Property {
      rooms {
        ... RoomAttributes
      }
    }
  `
}

export const Room = {
  attributes: gql`
    fragment RoomAttributes on Room {
      id
      name
      price
      description
    }
  `
}

As you can see, ApolloProxy.js contains two methods – listProperties and fetchProperty – each of which send a GraphQL query of the same name to our server. They both use the query fragments we’ve defined in Fragments.js, which allows us to not litter our API with multiple copies of lists of attributes for each API call.

What the hell is a Saga?

If you’re asking this question, don’t fret: a Saga is merely a way to describe business logic in a declarative way.

Let’s define our first saga: the rootSaga. In src/Sagas, add RootSaga.js:

import { all, takeLatest } from 'redux-saga/effects'
import navigationSaga from './NavigationSaga'

export default function* rootSaga() {
  yield takeLatest('ROUTER_LOCATION_CHANGED', navigationSaga)
}

It’s very simple. We are listening for the latest ROUTER_LOCATION_CHANGED action (which is triggered by our router), and when we see it, we execute the navigationSaga, which is defined in NavigationSaga.js:

import { call } from 'redux-saga/effects'
import invalidRouteSaga from './RouteSagas/InvalidRouteSaga'

export default function* navigationSaga(action) {
  const location = action.payload
  const saga = location.result.saga || invalidRouteSaga
  yield call(saga, location)
}

This Navigation Saga takes the payload from the ROUTER_LOCATION_CHANGED message, and executes another saga. Where does this magic “other” saga come from? Back in Routes.js, we defined a saga property for our first two routes. That’s the saga being executed here. Those two sagas were homeSaga and propertyDetailsSaga, which we will define in Sagas/RouteSagas:

Sagas/RouteSagas/HomeSaga.js:

import { all, put, takeEvery, select } from 'redux-saga/effects'
import { fetchEntities } from '../../Actions'
import fetchEntitiesSaga from '../FetchEntitiesSaga'

const TYPE_NAME = 'Property'
const API_ACTION = 'listProperties'

export default function* homeSaga(location) {
  yield takeEvery('FETCH_ENTITIES', fetchEntitiesSaga(TYPE_NAME, API_ACTION))
  const showing = yield select(s => s.app.Property.get('showing'))
  if (showing === -1) {
    yield all([
      put(fetchEntities(TYPE_NAME, API_ACTION)),
      put(fetchEntities(TYPE_NAME, API_ACTION))
    ])
  }
}

This one gets a little more complex. Remember that we’re displaying an infinitely scrollable list of Properties on this route. When we scroll to the bottom, we must do both of the following:

To fulfill these requirements, we must fetch two batches on page-load: the first of which will be displayed immediately, and the second will be displayed when the user scrolls to the bottom. This is why we defined showing to be -1 in our initial state: each time we trigger a FETCH_ENTITIES action, showing is incremented (see FetchEntitiesSaga.js below), but we actually only want to show the first batch of the initial two requests. So if we started it at 0, both initial batches would be displayed. This “stutter-step” allows us to provide an illusion of very fast loading for the user.

So back to this saga, it is doing in code what we just described: if this is the initial page-load (i.e., showing is -1), it triggers two fetchEntities actions. It also listens for every dispatch of FETCH_ENTITIES, and executes yet another saga: the fetchEntitiesSaga.

Create Sagas/FetchEntitiesSaga.js:

import { call, put, select  } from 'redux-saga/effects'
import * as api from '../Api/ApolloProxy'
import { FETCH_LIMIT } from '../Constants'
import {
  fetchEntitiesSuccess,
  fetchEntitiesError,
  showMore
} from '../Actions'
import R from 'ramda'

export default function fetchEntitiesSaga(entityName, apiAction) {
  return function* (action) {
    yield put(showMore())
    const batchIndex = yield select(st => st.app[entityName].get('showing'))
    try {
      const result = yield call(
        api[apiAction],
        action.args,
        R.merge(action.searchParameters, { skip: FETCH_LIMIT * batchIndex })
      )
      yield put(fetchEntitiesSuccess(
        action.entityName,
        result.data[apiAction],
        batchIndex
      ))
    }
    catch (e) {
      yield put(fetchEntitiesError(
        action.entityName,
        e.message,
        batchIndex
      ))
    }
  }
}

This Saga takes care of the logic for (you guessed it) fetching entities. The actual generator function is wrapped in a normal function that provides the entity name and corresponding API action to call (in this case, Property and listProperties, respectively).

The logic is pretty simple. First, it dispatches a showMore action, which increments our app state’s showing property. Then, it reads (incremented) showing from current app state, and assigns its value to batchIndex for the subsequent request. It then tries calling the API method with the appropriate parameters, but with a modification to searchParameters: it defines skip so that the server returns the correct batch of entities.

If the call was successful, we trigger a FETCH_ENTITIES_SUCCESS action that contains the resulting list and the batchIndex (from above, we know that the reducer will set the entities to app state at this batchIndex).

If the call fails, we trigger a FETCH_ENTITIES_ERROR action, which would do something like display a big red banner.

Let’s move on to the next route, /property/:id. Just like we defined a Saga for the home route, we’ll define a PropertyDetailsSaga for this one.

Sagas/RouteSagas/PropertyDetailsSaga.js:

import { put, takeLatest } from 'redux-saga/effects'
import fetchEntityDetailsSaga from '../FetchEntityDetailsSaga'
import { fetchEntityDetails } from '../../Actions'

export default function* propertyDetailsSaga(location) {
  yield takeLatest('FETCH_ENTITY_DETAILS', fetchEntityDetailsSaga('Property', 'fetchProperty'))
  yield put(fetchEntityDetails('Property', 'fetchProperty', { id: location.params.id }))
}

Another simple one. Listen for the latest FETCH_ENTITY_DETAILS, and trigger a fetchEntityDetailsSaga:

Sagas/FetchEntityDetailsSaga.js:

import { call, put  } from 'redux-saga/effects'
import * as api from '../Api/ApolloProxy'
import {
  fetchEntityDetailsSuccess,
  fetchEntityDetailsError
} from '../Actions'

export default function fetchEntityDetailsSaga(entityName, apiAction) {
  return function* (action) {
    try {
      const result = yield call(api[apiAction], action.args)
      yield put(fetchEntityDetailsSuccess(
        action.entityName,
        result.data[apiAction],
        action.args
      ))
    }
    catch (e) {
      yield put(fetchEntityDetailsError(
        action.entityName,
        e.message,
        action.args
      ))
    }
  }
}

This one is basically the same as fetchEntitiesSaga but without the batchIndex logic.

Let’s add one more: the InvalidRouteSaga

Sagas/RouteSagas/InvalidRouteSaga.js:

import { call } from 'redux-saga/effects'
import { displayError } from '../../Actions'

export default function* invalidRouteSaga(location) {
  yield call(displayError('This page does not exist.'))
}

It’s basically a placeholder at the moment, but it’s useful.

Whew… that’s about enough Saga fun for now!

And… Action!

One of the main benefits of using redux-saga is that it allows all of our actions to be simple objects. If we were using redux-thunk we’d have object actions mixed with asynchronous-callback actions which are tough to test. Let’s define our actions file:

src/Actions/index.js:

export const fetchEntities = (entityName, apiAction, args = {}, searchParameters = {}) => ({
  type: 'FETCH_ENTITIES',
  entityName,
  apiAction,
  args,
  searchParameters
})

export const fetchEntitiesSuccess = (entityName, entities, batchIndex) => ({
  type: 'FETCH_ENTITIES_SUCCESS',
  entityName,
  entities,
  batchIndex
})

export const fetchEntitiesError = (entityName, error, batchIndex) => ({
  type: 'FETCH_ENTITIES_ERROR',
  entityName,
  error,
  batchIndex
})

export const fetchEntityDetails = (entityName, apiAction, args = {}) => ({
  type: 'FETCH_ENTITY_DETAILS',
  entityName,
  apiAction,
  args
})

export const fetchEntityDetailsSuccess = (entityName, entity, args) => ({
  type: 'FETCH_ENTITY_DETAILS_SUCCESS',
  entityName,
  entity,
  args
})

export const fetchEntityDetailsError = (entityName, error) => ({
  type: 'FETCH_ENTITY_DETAILS_ERROR',
  entityName,
  error
})

export const displayError = msg => ({
  type: 'DISPLAY_ERROR',
  msg
})

Nice and simple, you can look at each action and see what it relates to and the data tagging along with it. Later, we’ll look at how it’s super simple to write unit tests that verify our application logic.

Constants

Let’s quickly define src/Constants.js, which for now will have a single, lonely value:

export const FETCH_LIMIT = 20

Components

We’ve finally arrived to the fun part of an application: the views! As mentioned earlier, we’ll keep each view and its associated files in folder under Components. For example, we’ll have a PropertyList view which will be housed in a structure like:

- App/
  - PropertyList/
    - PropertyListContainer.js
    - PropertyList.js
    - PropertyList.css

PropertyListContainer.js is the Redux container for our view, PropertyList.js is the React view, and of course PropertyList.css is for styling.

Speaking of styling, We could of course use something like Sass or Less for preprocessed CSS, but we’ll follow the thinking outlined here. I quite like the idea of keeping CSS files coupled with the components they will be styling, but it’s certainly a matter of preference.

We’ll take a deep dive into the views of our application in our next post. Keep on reading!