3. Going async
3.1. Talking to the backend.
There was never a story more full of pain than the story of Romeo and Juliet. Sorry, Flux and XHR. Every library had it's own take on it, every developer had it's own vision of it. If you'd asked ten Flux developers about the best place to make XHR request, you probably would have gotten ten different answers.
Luckily, those dark days are now behind us. Redux reigns and there's much less scrutiny about XHR requests, but still there are multiple implementation using libraries like redux-thunk, redux-promise, redux-saga, etc. We might use one of those, but it seems that we already have what we need - our new shiny middleware. We easily can update it to work asynchronously.
Actually all we have to do is not dispatch our action with data right away, but add random delay before calling it:
/shared/redux/dataMiddleware.js
import data from '../data.json';
export default store => next => action => {
if (action.type !== 'DATA_PLEASE') {
return next(action);
}
setTimeout(() => {
next({
type: action.resultType,
data: data[action.key]
});
}, Math.random() * (2000 - 500) + 500);
};
That's it! We're officially async - our modules will render data with a delay between 500 and 2000ms.
But of course, this is not what we actually mean by "async". "Async" means RESTful API, XHRs, error handling - the whole nine yards, so let's line out what we have to do.
Our middleware will make XHRs to RESTful API, receive results and dispatch an action with results - nothing new here. But, being responsible developers, we remember that errors do happen. The middleware should dispatch special action if error occurs and we should inform user about it.
We also want our application to be aware that XHR is under way, so that we can inform user that he has to patient for a bit, e.g. display spinner or some message. Naturally, we will have to hide spinner when XHR request is complete.
We'll start by replacing setTimeout()with actual XHR request to the API that we pretend exists at our backend. But how middleware will know what endpoint to talk to? Let's look at the action that modules dispatch asking for data:
{
"type": "DATA_PLEASE",
"resultType": "GALLERIES_DATA",
"key": "gallery"
}
Here we have key that we previously used to access parts of data from /shared/data.json. Why now can't it be an endpoint? Here's /shared/redux/dataMiddleware.js:
import fetch from 'isomorphic-fetch';
export default store => next => action => {
if (action.type !== 'DATA_PLEASE') {
return next(action);
}
let url = '/api/' + action.key;
fetch(url).then(response => {
if (response.status !== 200) {
return Promise.reject(response);
}
return response.json();
}).then(data => {
next({
type: action.resultType,
data: data
});
}).catch(response => {
// TODO
});
};
(“Fetch” API, which isomorphic-fetch library implements, has a funny way of handling failed requests, doesn't it?)
For now we assume that API exists and endpoints return same data that modules got from /shared/data.json.
Let's think about #1 and #2 from our plan - error handling and showing message while request is pending. We can accomplish this with dispatching actions - one before call to fetch() for message and another in catch block. But what part of our app should process these action?
We had this problem before with menu - we had to add App container to solve it, so why don't we make him do this also?
Here's updated App module reducer:
/shared/modules/app/reducer.js
const initialState = {
menu: [],
xhrPending: false,
xhrError: false
};
export default function app(state = initialState, action = {}) {
let newState;
switch(action.type) {
case 'APP_DATA_FETCHED':
newState = { ...state, ...action.data };
break;
case 'XHR_STARTED':
newState = { ...state, xhrPending: true, xhrError: false };
break;
case 'XHR_SUCCEEDED':
newState = { ...state, xhrPending: false, xhrError: false };
break;
case 'XHR_FAILED':
newState = { ...state, xhrPending: false, xhrError: true };
break;
default:
newState = state;
break;
}
return newState;
}
Here we add two fields to initial state. xhrPending will be true when XHR is under way. xhrError will be true in case error occurs. We also add case blocks to our reducer to handle actions and update state. It's time to dispatch these actions in middleware:
export default store => next => action => {
...
store.dispatch({ type: 'XHR_STARTED' });
fetch(url).then(response => {
if (response.status !== 200) {
return Promise.reject(response);
}
return response.json();
}).then(data => {
store.dispatch({ type: 'XHR_SUCCEEDED' });
next({
type: action.resultType,
data: data
});
}).catch(response => {
next({
type: 'XHR_FAILED',
error: response.statusText
});
});
};
Here we've added three dispatches: one for request's start, one for request's success and one for request's failure.
All we have to do now is to show "Loading..." message and error message to user. We will do in app module's presentational component:
/shared/modules/app/components/App.jsx
...
const App = ({ menu, children, xhrPending, xhrError }) => {
return (
<div>
<header>
<Menu items={menu}/>
</header>
{ xhrPending ? <div>Loading...</div> : null }
{ xhrError ? <div>All is lost!</div> : children }
<footer>© {(new Date()).getFullYear()}</footer>
</div>
);
};
…
Here we conditionally render Loading... while we wait for XHR to finish and "All is lost!" if it failed. All is ok, let's sit back and relax.
But wait - now if XHR has failed end error message is shown our menu doesn't work anymore! We click a link, see the URL change, but data is not rendered anymore - what's wrong? We will have to investigate, and I was so ready to have a drink to our success(
Before we do - take some time to run the examples from this section: clone the repo (https://github.com/graymur/another-react-universtal-tutorial), switch to branch "3.1", run npm install and npm start commands and navigate to http://localhost:3000. You will also need babel-node installed. You will find API implementation in /server/serve.js - we use our old friend data.json, which now lives in /server/ directory. You will also notice that blog endpoint deliberately returns 404, so that you can replicate the error we were talking about above.
3.2. Making the error go away
So, what's wrong with our app? Why does it stop working after XHR request fails? Let's recap the flow of our app:
We navigate to URL
/galleries. React-router checks the routes and picksGalleriescomponent to be rendered.Galleriescomponent is passed inprops.childrento ourappmodule's HoC. Then it gets to App presentational component aschildren={this.props.children}, where it is rendered.When
Galleriescomponent is rendered, it'scomponentDidMountmethod()is called and action with typeDATA_PLEASEis dispatched.Middleware catches
DATA_PLEASEaction and dispatches an action with typeXHR_STARTED, which is processed in/shared/modules/app/reducer.js.xhrPendingis set to true,xhrError- to false, "Loading..." message is displayed.Middleware starts XHR request.
XHR request finishes successfully, action with type
XHR_SUCCEEDEDis dispatched,appmodule's reducer setsxhrPendingto false andxhrErrorto false.Action with type
GALLERIES_DATA_FETCHEDis dispatched with XHR's results, is processed bygalleriesmodule's reducer. Galleries component receives data through props and renders it.Next we click on
/bloglink, steps 1-5 are repeated, but now XHR request fails. Action with typeXHR_FAILEDis dispatched by middleware, is processed byappmodule's reducer.xhrPendingis set to false and xhrError to true, so "Loading..." message is hidden and "All is lost" message appears. So far everything works as expected, but this is where it gets tricky.We click on
/gallerieslink. React-router again picksGalleriescomponent. It gets toappmodule's presentational component, where it should be rendered and request data. But inappmodule's presentational component we have this condition:
{ xhrError ? <div>All is lost!</div> : children }
Have you already spotted the problem? xhrError was set to true in the step #8 and is still true. "All is lost message" is rendered instead of Galleries component even if we are now in the route that works fine! Poor Galleries component never stood a chance.
What can we do about it? It's probably a good idea to set xhrError to false in the moment when the route changes, so that module of that route has a chance to request data. But where can we catch the route change?
react-router-redux library automagically provides our "connected" HoCs with a lot of useful data about current router state through properties:

This data is updated on every route change. It means our HoCs receive new props, and if we see that location has changed we can set xhrError to false. As xhrError is managed by our app module, we will use it's HoC to do these checks. React provides us with componentWillReceiveProps() lifecycle method, which is the perfect place to put this new code. We can dispatch an action and set xhrError to false in app module's reducer.
Let's create an action first:
/shared/modules/app/actions.js
export const clearXhrError = () => ({
type: 'CLEAR_XHR_ERROR'
});
Now make reducer process it:
/shared/modules/app/reducer.js
...
export default function app(state = initialState, action = {}) {
let newState;
switch(action.type) {
....
case 'CLEAR_XHR_ERROR':
newState = { ...state, xhrError: false };
break;
...
}
return newState;
}
And finally make our HoC do the check and dispatch an action:
/shared/modules/app/container.jsx
...
import { fetchAppData, clearXhrError } from './actions.js';
...
class AppContainer extends React.Component {
...
componentWillReceiveProps(props) {
if (props.location.pathname !== this.props.location.pathname) {
this.props.dispatch(clearXhrError());
}
}
...
}
...
Now everything will work properly even after that stupid Blog router fails!
You can find all code of this section here: https://github.com/graymur/another-react-universtal-tutorial. Clone the repo, switch to branch "3.2", run npm install and npm start commands and navigate to http://localhost:3000. You will also need babel-node installed.
3.3. Bringing it home
Now have to do couple of things. First - make out Blog router great again (pun intended). Second - remember our goal #1, where we pledged to make text pages, plural.
Fixing Blog route is easy - we just remove our artificial error. Creating multiple pages is a bit harder.
Right now we have a single page, but our Page route was special from the beginning. Instead of particular URL it matches any URL with path="*". Any URL like /about or /contacts will be matched by Page route. But right now same page titled "Content page" is rendered on both of those URLs.
This means we will have to:
- grab the current URL,
- pass it to our middleware,
- make request to API,
- dispatch action with result or error.
Sounds like a lot, but we actually have steps 2-3 covered already.
First, let's add a couple of elements to our menu so that we don't have to type them manually:
/server/data.json
{
"app": {
"menu": [{
....
}, {
"link": "/about",
"title": "About"
}, {
"link": "/contacts",
"title": "Contacts"
}]
},
}
And we update "page" part of our state so it now contains two pages:
/server/data.json
...
"page": {
"about": {
"title": "About page",
"content": "..."
},
"contacts": {
"title": "Contacts page",
"content": "..."
}
}
}
Now let's see what can we use to get the URL of the current page:
- window.location.pathname that browser gives us;
- this.props.location.pathname in our HoC, which is the same;
- we can inspect HoC's properties again.
When we navigate to URLs /about or /contacts, React-router sees that they match route with path="*". It adds special parameter to props called "splat", which contains a string match by "*" symbol. In our case it's almost identical to props.location.pathname ("about" and "contacts"respectively). But using it feels kind of right.
Now we have to pass splat to API, which means passing it to our middleware. The action that middleware intercepts has key field that corresponds to API endpoint. We need to add query parameter to it, so URL /api/page?splat=contacts is requested instead of just /api/page. Our middleware cannot handle query parameters, so we'll have some updating to do.
First, let's decide how out DATA_PLEASE action will carry these parameters. We want other modules to be able to pass parameters too, so we can't just hardcode it for our page module. It can be something like this:
{
"type": "DATA_PLEASE",
"resultType": "PAGE_DATA_FETCHED",
"key": "page",
"payload": {
"splat": "contacts"
}
}
Here we add payload field. It can contain any number of parameters - the middleware will just have to make query string of them and append to API endpoint's URL. For our page module we need only one query parameter - splat. (by the way I don't know what "splat" means and at this point I'm too afraid to ask).
Let's update page module's action:
/shared/modules/page/actions.js
export const fetchPageData = (splat) => ({
type: 'DATA_PLEASE',
resultType: 'PAGE_DATA_FETCHED',
key: 'page',
payload: {
splat
}
});
And pass splat to it in HoC:
/shared/modules/page/container.jsx
...
class PageContainer extends React.Component {
componentDidMount() {
this.props.dispatch(fetchPageData(this.props.params.splat));
}
...
}
...
Now we have to teach our middleware to transform payload object into query string:
/shared/redux/dataMiddleware.js
...
export default store => next => action => {
...
let url = '/api/' + action.key;
if (action.payload) {
url += '?' + Object.keys(action.payload).map(k => k + '=' + encodeURIComponent(action.payload[k])).join('&');
}
...
};
And now we will query /api/page?splat=contacts for /contacts URL and /api/page?splat=about for /about.
Ok, let's see if it works! We navigate to /contacts and see title "Contacts page" - good! We then navigate to /about and see ... "Contacts page". WUT?
Let's once again recap the flow:
We navigate to
/contacts. React-router picksPagecomponent. React renders it.DATA_PLEASEaction is dispatched. Request to/api/page?splat=contactsis made.Pagecomponent receives data and updates.We navigate to
/about. React-router picksPagecomponent again. But this time React sees that it's already rendered and doesn't rerender it.componentDidMount()method is not called and data for new page is not fetched!
We will have to make DATA_PLEASE action to be dispatched on route change. We've done something similar trying to clear xhrError flag on route change. We've used app module's HoC's componentWillReceiveProps method for that. Let's see if we can use this method here:
/shared/modules/page/container.jsx
...
class PageContainer extends React.Component {
componentDidMount() {
this.props.dispatch(fetchPageData(this.props.params.splat));
}
componentWillReceiveProps(props) {
this.props.dispatch(fetchPageData(props.params.splat));
}
...
}
...
It works, kind of. DATE_PLEASE action is now dispatched on route change, but with unpleasant side effect. Action gets through all the tubes, Page component receives props. componentWillReceiveProps() is called again and DATA_PLEASE action is dispatched again in endless cycle.
componentWillReceiveProps() receives nextProps object as an argument. We need DATA_PLEASE action to be dispatched only when splat changes. We can compare nextProps.params.splat to this.props.params.splat and dispatch DATA_PLEASE event only if they are not equal. We can also abstract this check into new method:
/shared/modules/page/container.jsx
...
class PageContainer extends React.Component {
componentDidMount() {
this.feetchIfNeeded(this.props);
}
componentWillReceiveProps(props) {
this.feetchIfNeeded(props);
}
feetchIfNeeded(nextProps) {
if ((!this.props.page.title && !nextProps.page.title) || nextProps.params.splat !== this.props.params.splat) {
this.props.dispatch(fetchPageData(nextProps.params.splat));
}
}
...
}
...
We create new feetchIfNeeded method where we check if splat in current props is equal to splat in new props. We also check if title on props is empty, so that we load data on first page load.
Now /contacts and /about routes work fine.
You'll notice that if we navigate to /contacts, then to /about and then back to /contacts, XHR to /api/page?splat=contacts is sent again. We can get rid of it by caching results in the middleware or by refactoring the structure of the state of page module. But it's a story for another time.
You can find all code of this section here: https://github.com/graymur/another-react-universtal-tutorial. Clone the repo, switch to branch "3.3", run npm install and npm start commands and navigate to http://localhost:3000. You will also need babel-node installed.