Now Available React Programming: The Big Nerd Ranch Guide
Front-End ReactBased on our React Essentials course, this book uses hands-on examples to guide you step by step through building a starter app and a complete,...
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.
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.
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.
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.
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.
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.
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 await
ing 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”:
Change it to the value “Fast 3G”–this will slow down the request so we can see it in the “loading” state:
Then click the Reload button you added to the app. You should see the animated preloader briefly before the list appears.
Check the “Offline” checkbox, then press the Reload button again. You should see the preloader again, then the error message.
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.
Based on our React Essentials course, this book uses hands-on examples to guide you step by step through building a starter app and a complete,...
Svelte is a great front-end Javascript framework that offers a unique approach to the complexity of front-end systems. It claims to differentiate itself from...
Large organizations with multiple software development departments may find themselves supporting multiple web frameworks across the organization. This can make it challenging to keep...