Search

React Data Layer – Part 4: Backend Data

Josh Justice

11 min read

May 21, 2019

React Data Layer – Part 4: Backend Data

Editor’s Note: The API used by this blog series is no longer available. As a result, you will not be able to run this application locally, but you can read the code and explanations to see how to build a robust real-world frontend app data layer.

This post is the fourth part of a React Data Layer 8-part series going in-depth into how to build a robust real-world frontend app data layer. See the previous parts here:

In this post, we’ll switch from storing our data only locally in-memory to reading it from the web service and writing it back there. This step moves our app to the point where we could actually use it for production. We’ll look into patterns we can use to organize our web service requests, status reporting, and data returned.

Setting Up Thunk

To connect to a web service, we need a mechanism to handle our asynchronous web requests. For our purposes, the Redux Thunk library will do nicely. It allows you to run asynchronous code in your Redux action creators, dispatching actions after they are complete. Install it:

$ yarn add redux-thunk

Next, add it to your store setup in src/store/index.js:

-import { createStore } from 'redux';
+import { createStore, applyMiddleware, compose } from 'redux';
 import { devToolsEnhancer } from 'redux-devtools-extension';
+import thunk from 'redux-thunk';
 import { persistStore, persistReducer } from 'redux-persist';
 import storage from 'redux-persist/lib/storage';
 import rootReducer from './reducers';
...
 const store = createStore(
   persistedReducer,
-  devToolsEnhancer(),
+  compose(
+    applyMiddleware(thunk),
+    devToolsEnhancer(),
+  ),
 );

Instead of being able to pass the devToolsEnhancer() directly, we now need to apply the thunk middleware as well, so we use the Redux compose() method to compose the two enhancers into a single enhancer to pass.

Loading Data

Now it’s time for us to load data from the service.

First, let’s add an action creator to do so. Add the following to store/games/actions.js:

+import api from '../api';
+
+export const STORE_GAMES = 'STORE_GAMES';
 export const ADD_GAME = 'ADD_GAME';

+export const loadGames = () => async (dispatch) => {
+  const { data: responseBody } = await api.get('/games');
+  dispatch({
+    type: STORE_GAMES,
+    games: responseBody.data,
+  });
+};
+
 export const addGame = (title) => {
   return {

When using Redux Thunk, action creator functions return another function, which takes a dispatch parameter that can be used to dispatch actions. We use arrow function syntax so we can concisely show a function returning another function. We are also using ECMAScript async/await syntax to simplify the asynchronous network call. We send a GET request to the /games endpoint. We destructure the response object, assigning the data property to a responseBody variable. You can see examples of JSON:API response formats at https://sandboxapi.bignerdranch.com/; here’s an example of a response to GET /games:

{
    "data": [
        {
            "id": "1",
            "type": "games",
            "links": {
                "self": "https://sandboxapi.bignerdranch.com/games/1"
            },
            "attributes": {
                "title": "Final Fantasy 7",
            }
        }
    ]
}

Next, we dispatch a new STORE_GAMES action, and we pass it the data property of responseBody. Wondering why there are two nested data properties? The first is defined by Axios on its response object to make the response body available, and the second is a field within the response body defined by the JSON:API specification. You can see that "data" field in the sample response above.

Next let’s handle the STORE_GAMES action in our reducer. Add the following to store/games/reducers.js:

import {
   ADD_GAME,
+  STORE_GAMES,
 } from './actions';

-const initialData = [
-  'Fallout 3',
-  'Final Fantasy 7',
-];
-
-export function games(state = initialData, action) {
+export function games(state = [], action) {
   switch (action.type) {
+    case STORE_GAMES:
+      return action.games;
     case ADD_GAME:
       return [action.title, ...state];

Because the server is now providing our data, we no longer need the initialData, so we remove it, setting the reducer’s initial data to an empty array. When STORE_GAMES is dispatched, we replace the games reducer’s state with the games property passed in the action.

Now that our loadGames action creator is done, let’s wire it up to our UI. First, in GameList/index.js, add it to mapDispatchToProps:

import {
+  loadGames,
   addGame,
 } from 'store/games/actions';
...
 const mapDispatchToProps = {
+  loadGames,
   addGame,
   logOut,
 };

This will make loadGames available to our GameList component as a prop. Now we can call it when the GameList mounts:

-import React from 'react';
+import React, { useEffect } from 'react';
 import {
   Button,
   Collection,
...
 const GameList = ({
   games,
+  loadGames,
   addGame,
   logOut,
 }) => {
+  useEffect(() => {
+    loadGames();
+  }, []);
+
   return <div>
     <AddGameForm onAddGame={addGame} />
     <Button onClick={logOut}>

useEffect will dispatch our loadGames action when the component mounts. We pass an empty array to useEffect to let it know that there are no state items that should cause the effect to be rerun, so it will only run once, when the component mounts.

We need to make one more change to GameList as well. Previously, when we were only working with local data, we stored the titles of the games directly in the reducer. Now, though, entire JSON:API records are being stored. Here’s an example record again:

{
    "id": "1",
    "type": "games",
    "links": {
        "self": "https://sandboxapi.bignerdranch.com/games/1"
    },
    "attributes": {
        "title": "Final Fantasy 7",
    }
}

We need to update our render method to take account for this new format:

 <Collection>
   { games.map((game) => (
-    <CollectionItem key={game}>{game}</CollectionItem>
+    <CollectionItem key={game.id}>{game.attributes.title}</CollectionItem>
   )) }
 </Collection>

Now that we have a real ID field we can use that for the key prop instead of the name. This will prevent collisions as long as the server returns a unique ID for each record. To display the game’s title, we retrieve it from the attributes.title property.

If you run the app now, you’ll likely see this error:

Unhandled Rejection (TypeError): Cannot read property 'title' of undefined

This is because we’re restoring our Redux state from where it was persisted, and the games we have from before don’t have an attributes property: they’re just strings. This illustrates one of the challenges of persisting data: you have to be aware that past users will have data in previous formats, so you will need to manually migrate it.

In our case, though, we aren’t in production, so we can just clear out our persisted state. Open the Chrome web developer tools, go to Application > Storage > Local Storage > http://localhost:3000, and click the circle with a line through it. This will remove your persisted state.

clear icon

Reload and you’ll need to log back in again, but after you do, records should be pulled down from the server successfully. You should now see some sample records returned from the server; these were set up for you when you created your account.

Saving Data

Now let’s set up an action creator to add a record. To do this, we need to change our addGame action creator from synchronous to asynchronous. Replace addGame with the following:

export const addGame = (title) => async (dispatch) => {
  const game = {
    type: 'games',
    attributes: { title },
  };
  const { data: responseBody } = await api.post('/games', { data: game });
  dispatch({
    type: ADD_GAME,
    game: responseBody.data,
  });
};

First, we construct a game object in the format JSON:API requires. We add a type property to indicate that it’s a game. Then we include an attributes object, which for us is just the title. We POST it to the /games endpoint. As with the loading endpoint, we destructure the data property into the responseBody variable, then retrieve the data property from it. This will be the complete record returned by the server, including the id it assigned. This complete record is what we pass along with the ADD_GAME dispatch.

We also need to make a tiny change to games/reducers.js. Before, we were passing only a title property with ADD_GAME, but now we are passing an entire game. We update the games reducer to retrieve the correct property:

     case STORE_GAMES:
       return action.games;
     case ADD_GAME:
-      return [action.title, ...state];
+      return [action.game, ...state];
     default:
       return state;

This change helps make it clear that we are now storing an entire game, rather than just the title.

With this, our data should now save to the server. Reload the app and add a new record. Then reload the page again. The record you added should still appear.

Reloading

Another nicety we could add is a “Reload” button. Say a user is logged into our app on multiple devices. If they add a game on one device, it won’t show up on the other device. Let’s add a reload button to re-request the data from the server.

Implementing this is very simple: in addition to calling the loadGames action creator in componentDidMount, we also need to bind it to a button. Make the following change in GameList.js:

   return <div>
     <AddGameForm onAddGame={addGame} />
+    <Button onClick={loadGames}>
+      Reload
+    </Button>
     <Button onClick={logOut}>
       Log Out
     </Button>

Try it out; you will probably not see any difference in the UI, but check your Network tab to see that another request is sent.

Loading and Error States

Our app can now read and write data to the server–now let’s think about ways we can improve it. It’d be nice if we could indicate to the user if content was being loaded, as well as if there was an error. To do this, we need to track loading and error states in our store.

First, let’s create actions to record these status changes. Add the following to store/games/actions.js:

 export const STORE_GAMES = 'STORE_GAMES';
 export const ADD_GAME = 'ADD_GAME';
+export const START_LOADING = 'START_LOADING';
+export const RECORD_ERROR = 'RECORD_ERROR';

 export const loadGames = () => async (dispatch) => {
-  const { data: responseBody } = await api.get('/games');
-  dispatch({
-    type: STORE_GAMES,
-    games: responseBody.data,
-  });
+  dispatch({ type: START_LOADING });
+  try {
+    const { data: responseBody } = await api.get('/games');
+    dispatch({
+      type: STORE_GAMES,
+      games: responseBody.data,
+    });
+  } catch {
+    dispatch({ type: RECORD_ERROR });
+  }
 };

Before we make the GET request, we dispatch the START_LOADING action. We add a try/catch block so that if the promise we’re awaiting rejects, we will catch the error. If there’s an error, we dispatch the RECORD_ERROR action.

Now we need to handle these actions in games/reducer.js. First, import our new actions:

 import {
   ADD_GAME,
   STORE_GAMES,
+  START_LOADING,
+  RECORD_ERROR,
 } from './actions';

Next, let’s create a new reducer for each of the loading and error flags. First, loading:

export function loading(state = false, action) {
  switch (action.type) {
    case START_LOADING:
      return true;
    case STORE_GAMES:
    case RECORD_ERROR:
      return false;
    default:
      return state;
  }
}

The loading flag starts as false. When we dispatch START_LOADING before the request, loading is updated to true. We then set it back to false when either the request succeeds and we STORE_GAMES, or the request fails and we RECORD_ERROR.

Next, let’s set up the error reducer:

export function error(state = false, action) {
  switch (action.type) {
    case RECORD_ERROR:
      return true;
    case START_LOADING:
    case STORE_GAMES:
      return false;
    default:
      return state;
  }
}

The error flag starts as false. When we dispatch RECORD_ERROR upon an error, error is updated to true. If a new request starts, START_LOADING will set it back to false. We also set it to false if a request succeeds and we dispatch STORE_GAMES. We don’t strictly need to do this because START_LOADING should have set it to false already, but it can make our reducer more robust if we end up sending multiple web service requests at the same time in the future.

These new reducers illustrate some of the power of how Redux decouples actions from the code that handles them. Multiple reducers are responding to the same events. This decoupling keeps all the logic around one piece of state in one place; for example, it’s easier to see when the error flag is set and unset, to catch possible errors in our implementation.

To complete the updates to games/reducers.js, add the new reducers to the combineReducers() function call:

 export default combineReducers({
   games,
+  loading,
+  error,
 });

Now that we have loading and error flags in our store, we need to display the corresponding indicators in the UI. This is a separate and potentially reusable concern, so let’s create a LoadingIndicator component to handle this. Create a src/components/LoadingIndicator.js file and add the following:

import React from 'react';
import { Preloader } from 'react-materialize';

const LoadingIndicator = ({ loading, error, children }) => {
  if (loading) {
    return <div>
      <Preloader size="small" />
    </div>;
  } else if (error) {
    return <p>An error occurred.</p>;
  } else {
    return <div>
      {children}
    </div>;
  }
};

export default LoadingIndicator;

It’s a pretty straightforward component: we pass loading and error props to it, as well as some JSX children. If loading is true we display an indicator; if error, the message; otherwise, we display the children.

Now let’s set up GameList/index.js to pass these new state items to GameList:

 function mapStateToProps(state) {
   return pick(state.games, [
     'games',
+    'loading',
+    'error',
   ]);
 }

And now in GameList we’ll add the LoadingIndicator component and pass these props to it:

...
   CollectionItem,
 } from 'react-materialize';
 import AddGameForm from './AddGameForm';
+import LoadingIndicator from 'components/LoadingIndicator';

 const GameList = ({
   games,
+  loading,
+  error,
   loadGames,
   addGame,
   logOut,
...
     <Button onClick={logOut}>
       Log Out
     </Button>
-    <Collection header="Video Games">
-      { games.map((game) => (
-        <CollectionItem key={game.id}>{game.attributes.title}</CollectionItem>
-      )) }
-    </Collection>
+    <LoadingIndicator loading={loading} error={error}>
+      <Collection header="Video Games">
+        { games.map((game) => (
+          <CollectionItem key={game.id}>{game.attributes.title}</CollectionItem>
+        )) }
+      </Collection>
+    </LoadingIndicator>
   </div>;

We nest the existing <Collection> inside the LoadingIndicator as its children; as we saw in the implementation of LoadingIndicator, the children will only be rendered when the data is not loading or errored.

Now we should be ready to see these states in action. Reload your app, then in the Network tab find the dropdown that says “No throttling”:

throttling dropdown

Change it to the value “Fast 3G”–this will slow down the request so we can see it in the “loading” state:

fast 3G

Then click the Reload button you added to the app. You should see the animated preloader briefly before the list appears.

preloader

Check the “Offline” checkbox, then press the Reload button again. You should see the preloader again, then the error message.

What’s Next? More About the React Data Layer

With this, our app is now fully hooked up to the backend for data reading and writing. For a lot of applications, this is all you need, so you can stop right here. But you may want to take advantage of some offline features to improve your users’ experience. In the remaining posts we’ll look into doing so, talk about the costs and risks, and evaluate when it’s worth it to do so.

Click here for Part 5

Josh Justice

Author Big Nerd Ranch

Josh Justice has worked as a developer since 2004 across backend, frontend, and native mobile platforms. Josh values creating maintainable systems via testing, refactoring, and evolutionary design, and mentoring others to do the same. He currently serves as the Web Platform Lead at Big Nerd Ranch.

Speak with a Nerd

Schedule a call today! Our team of Nerds are ready to help

Let's Talk

Related Posts

We are ready to discuss your needs.

Not applicable? Click here to schedule a call.

Stay in Touch WITH Big Nerd Ranch News