Learning React 8 - AJAX continued

Written by Liam McLennan

The last post started implementing our new demo application - the movie library. We were forced to introduce a React class component with state to make the search form work.

When we type in the search text box and submit the form the expected action is sent to the redux store, as you can see in this screenshot featuring redux-devtools-extension.

Movie search

Routing

For this example we are going to use redux-first-router. As mentioned previously redux-first-router binds routes to actions. We must handle those actions and update the UI accordingly.

See the redux-router documentation for the full documentation, but we need to make index.js look like this:

import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import registerServiceWorker from './registerServiceWorker';
import { createStore, combineReducers, applyMiddleware, compose } from 'redux';
import { Provider } from 'react-redux';
import { connectRoutes } from 'redux-first-router';
import createHistory from 'history/createBrowserHistory';
import App from './App';
import {searchReducer} from './Search';

const routesMap = { 
  HOME: '/'
};

const { reducer, middleware, enhancer } = connectRoutes(createHistory(), routesMap)

const store = createStore(combineReducers({
    location: reducer,
    search: searchReducer
}), compose(enhancer, applyMiddleware(middleware),
window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__()));

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

store.dispatch({type: "START"});

registerServiceWorker();

routesMap defines the mapping between action types and routes. Here we are saying that the route / should map to the HOME action type. The redux-first-router state is put into the store on the location key.

Next we connect the App component to the Redux store:

function App({type}) {
    const componentMap = {
      HOME: ConnectedSearch
    };
    const View = componentMap[type];

    return (
      <div className="App">
        <header className="App-header">
          <h1 className="App-title">Movie Library</h1>
        </header>
        <View />
      </div>
    );
}

export default connect(function mapStateToProps(state) {
  return state.location;
}, 
(dispatch) => ({}))(App);

redux-first-router puts route information into the redux store when a route is matched. mapStateToProps extracts the location key from the store, which is then used in the App component to extract the type value.

componentMap maps route action types to React components. If the route action type is HOME then View will be a ConnectedSearch, which is what we want for the home page.

AJAX Actions

The difficulty we encounter when trying to search for a movie (via an AJAX request) is that we can’t take the search action ({ type: "SEARCH", title: "Term" }) and update the UI state from that. We need to make an asynchronous request in the middle.

Nearly every React application must overcome this difficulty. There are lots of different solution and I will present one.

First, install redux-promise-middleware.

$ npm install redux-promise-middleware --save

redux-promise-middleware is a library that intercepts actions that carry a promise (SEARCH). When the promise resolves the library dispatches a new action with the result (SEARCH_FULFILLED).

We create a promise using the fetch API to make a request to omdbapi to search for movies by title.

We dispatch:

dispatch({
    type: 'SEARCH',
    payload: fetch(`http://www.omdbapi.com/?apikey=8e4dcdac&s=${encodeURIComponent(title)}`)
        .then((response) => response.json())
});

and in the reducer look for SEARCH_FULFILLED:

export function searchReducer(state = {results: []}, action) {
    switch (action.type) {
        case "SEARCH_FULFILLED":
            return Object.assign(
                {}, 
                { results: action.payload.Response 
                        ? action.payload.Search.filter(({Poster}) => Poster !== "N/A") 
                        : []});
        default: return state;
    }
}

Update the Search component to display our recently acquired movie data:

function Search({ onSearch, results = [] }) {
    return <div>
        <h1>Search</h1>
        <SearchForm onSearch={onSearch} />
        <div>
            {results.map(({Title,Poster,imdbID})=> 
                <img src={Poster} alt={Title} key={imdbID} />)}
        </div>
    </div>;
}

Now look at our amazing application!

Movie library search terminator

The complete Search.js is:

import React from 'react';
import { connect } from 'react-redux';

class SearchForm extends React.Component {
    constructor(props) {
        super(props);
        this.state = {title: ""}
        this.titleChange = this.titleChange.bind(this);
        this.search = this.search.bind(this);
    }

    titleChange(event) {
        this.setState({title: event.target.value});
    }

    search(event) {
        event.preventDefault();
        this.props.onSearch(this.state.title);
    }

    render() {
        return <div><form onSubmit={this.search}>
            <label htmlFor="title">Title: </label>
            <input type="text" name="title" value={this.state.title} onChange={this.titleChange}/>
            <input type="submit" value="Search"/>
        </form>
        </div>;
    }
}

function Search({ onSearch, results = [] }) {
    return <div>
        <h1>Search</h1>
        <SearchForm onSearch={onSearch} />
        <div>
            {results.map(({Title,Poster,imdbID})=> <img src={Poster} alt={Title} key={imdbID} />)}
        </div>
    </div>;
}

export const ConnectedSearch = connect(
    function mapStateToProps(state) {
        return state.search;
    }, 
    function mapDispatchToProps(dispatch) {
        return {
            onSearch: (title)=> {
                dispatch({
                    type: 'SEARCH',
                    payload: fetch(`http://www.omdbapi.com/?apikey=8e4dcdac&s=${encodeURIComponent(title)}`)
                            .then((response) => response.json())
                  });
            }
        };
    }
)(Search);

export function searchReducer(state = {results: []}, action) {
    switch (action.type) {
        case "SEARCH_FULFILLED":
            return Object.assign(
                {}, 
                { results: action.payload.Response 
                        ? action.payload.Search.filter(({Poster}) => Poster !== "N/A") 
                        : []});
        default: return state;
    }
}

Summary

First, we setup redux-first-router and implemented the HOME route. Then we introduced redux-promise-middleware as a way of inserting asynchronous operations into the redux data cycle.

Next: Learning React 9 - Data Bound Routes

Get The Code

The code for this example is on Github. You can access the code as it was at the completion of this step by cloning the repository and checking out the tag that corresponds to this post.

git clone https://github.com/liammclennan/movie-library.git
git checkout react8

or browse at https://github.com/liammclennan/movie-library/tree/c93c652fa1318ce6447d985e3f8b690386456565.