« Previous Tutorial Next Tutorial »

Welcome back to Five Minute React. We’re getting down to where the rubber meets the road, here, and starting to put together the main functionality of our app. At this point, you should have a solid understanding of how actions broadcast data, reducers listen for it and update the Store, and components render based on what’s in their props. At least, I hope you’ve got that understanding, because things are about to speed up.

The big secret about API building is that a LOT of it is basic repetition of a few core concepts. Much of React development is like this as well; you just sometimes have to get fancy to perform particular data manipulations or give the user a better experience when interacting with your app.

OK, so, all of that said, I’m going to throw more code at you, and spend less time explaining stuff that I think you should already get. If this gets too overwhelming, intimidating, complex, etc … do not hesitate to leave comments! The goal of this series is to help people become confident React developers. If it’s not doing that, then that’s a problem!

Cool? Good. On we go. We’re going to start out by building a search form and creating an API endpoint for it to talk to. If we have time, we’ll cover what to do with the response. If not, that’ll go in the next tutorial.

First, let’s create an API endpoint for searching the Discogs database. We’re doing this in a slightly weird manner because the disconnect module doesn’t play well with front-end code, so we’re going to contact our API, which will then reach out to the Discogs API. Don’t worry, it’s not too complicated. Head for /routes/api/ and create a file called albums.js. In that file, insert the following imports and related functionality:

const appConfig = require('../../config.js');
const Discogs = require('disconnect').Client;
const express = require('express');
const mongoose = require('mongoose');

const router = express.Router();

// configure mongoose promises
mongoose.Promise = global.Promise;

// configure Discogs
const discogsClient = new Discogs('MusicList-closebrace/0.1', {
  consumerKey: appConfig.discogs.key,
  consumerSecret: appConfig.discogs.secret,
});
const discogsDB = discogsClient.database();

And then add a padding line below that, and let’s write out a very simple search route, like this:

// POST to /search
router.post('/search', async (req, res) => {
  // Contact Discogs API
  await discogsDB.search(req.body, (err, data) => {
    if (err) {
      const error = new Error(err);
      return res.json(error);
    }
    return res.json(data);
  });
});

module.exports = router;

That’s it. We’ll handle telling Discogs what to search for in our action file. Speaking of which, save this file and then create a new one in /src/actions called albums.js. This one’s a little longer, but it’s going to look very familiar. Some action creators, and then a method that uses fetch to POST to the API. Here are the imports and action creators:

import 'whatwg-fetch';
import { decrementProgress, incrementProgress } from './progress';
import { clearError } from './error';

// Action Creators
export const albumSearchClear = () => ({ type: 'MUSIC_ALBUM_SEARCH_CLEAR' });
export const albumSearchFailure = error => ({ type: 'MUSIC_ALBUM_SEARCH_FAILURE', error });
export const albumSearchSuccess = json => ({ type: 'MUSIC_ALBUM_SEARCH_SUCCESS', json });

And here’s the search method:

// Search Albums
export function searchAlbums(searchText) {
  return async (dispatch) => {
    // clear the error box if it's displayed
    dispatch(clearError());

    // turn on spinner
    dispatch(incrementProgress());

    // Build packet to send to Discogs API
    const searchQuery = {
      q: searchText,
      type: 'master',
      format: 'album',
    };

    // Send packet to our API, which will communicate with Discogs
    await fetch(
      // where to contact
      '/api/albums/search',
      // what to send
      {
        method: 'POST',
        body: JSON.stringify(searchQuery),
        headers: {
          'Content-Type': 'application/json',
        },
        credentials: 'same-origin',
      },
    )
    .then((response) => {
      if (response.status === 200) {
        return response.json();
      }
      return null;
    })
    .then((json) => {
      if (json.results) {
        return dispatch(albumSearchSuccess(json));
      }
      return dispatch(albumSearchFailure(new Error(json.error)));
    })
    .catch(error => dispatch(albumSearchFailure(new Error(error))));

    // turn off spinner
    return dispatch(decrementProgress());
  };
}

The only thing to note here is the data packet we’re sending to Discogs, which is using type: 'master' and format: 'album'. This is important because if we didn’t set the type to master, we’d get every single version of an album, instead of the Discogs master listing for it. As an example, I looked up the recent release “Villains” by Queens of the Stone Age, and there are 37 versions of that album alone (many of them just differing by what country they were released in). So if you searched for an artist like, say, The Beatles, without setting your type to master, you’d probably get thousands of results. Not very useful!

Save this file, and now we need a reducer to listen to our actions. So, in /src/reducers/, create yet another file called albums.js, and in it, add the following code:

const initialState = [];
export default function reducer(state = initialState, action) {
  switch (action.type) {
    case 'MUSIC_ALBUM_SEARCH_SUCCESS': {
      const newState = action.json.results.slice();
      return newState;
    }
    case 'MUSIC_ALBUM_SEARCH_CLEAR': {
      const newState = initialState.slice();
      return newState;
    }
    default: {
      return state;
    }
  }
}

And of course we need to catch the failure action as well, so save that file and open /src/reducers/error.js. Remove the close brace from line 12 and directly below it, add this line:

    case 'MUSIC_ALBUM_SEARCH_FAILURE': {

We’re good here. Save the file and open /src/reducers/index.js. We need to add our new reducer to the Store, so under line 1, add this code:

import AlbumsReducer from '../reducers/albums';

And below line 7, go with this:

  albums: AlbumsReducer,

That’s all we need here, so save the file and head for /app.js. We need to wire up our new API route. That’s going to happen on line 24, but while we’re here let’s clean up and alphabetize this entire block. I suggest this code:

// Route Files
const api = require('./routes/api/index');
const albums = require('./routes/api/albums');
const authentication = require('./routes/api/authentication');
const index = require('./routes/index');
const users = require('./routes/api/users');

Now scroll down to line 67 and just below it, add this:

app.use('/api/albums', albums);

While you’re at it, move the authentication line up above the users line for alphabetical ordering purposes. Good. We’re done here! Save the file, and let’s create a new folder in /src/components called albums. Then, in that folder, create a new file called AlbumsPageContainer.jsx. This is going to look very familiar at this point. Here it is:

import React from 'react';
import { connect } from 'react-redux';
import { albumSearchClear, searchAlbums } from '../../actions/albums';

import AlbumsPage from './AlbumsPage';

export class AlbumsPageContainer extends React.Component {
  constructor(props) {
    super(props);

    // bound functions
    this.searchAlbumsFunction = this.searchAlbumsFunction.bind(this);
  }

  componentWillUnmount() {
    const { dispatch } = this.props;
    dispatch(albumSearchClear());
  }

  searchAlbumsFunction(searchText) {
    const { dispatch } = this.props;
    dispatch(searchAlbums(searchText));
  }

  render() {
    const { albums } = this.props;
    return (
      <AlbumsPage
        albums={albums}
        searchAlbumsFunction={this.searchAlbumsFunction}
      />
    );
  }
}

const mapStateToProps = state => ({ albums: state.albums });

export default connect(mapStateToProps)(AlbumsPageContainer);

You’ll note that we’re storing any returned albums from the reducer here, and clearing it when the component’s going to unmount, which is just a nice little bit of state management … no need to keep all that data floating around if it’s not in use!

Save the file and create another new file in /src/components/albums called—you guessed it!—AlbumsPage.jsx. This is probably the longest chunk of code I’ve ever given you guys other than a “here’s what the whole file should look like” wrapup … so here we go!

import React from 'react';
import { AvForm, AvGroup, AvInput, AvFeedback } from 'availity-reactstrap-validation';
import { Button, Label } from 'reactstrap';

// helpers
const listAlbums = albums => albums.map(album =>
  (
    <p key={album.id}>
      <img src={album.thumb} alt="album thumbnail" />
      <strong>Title: {album.title}</strong><br />
    </p>
  ),
);

export default class AlbumsPage extends React.Component {
  constructor(props) {
    super(props);

    // bound functions
    this.handleSearchChange = this.handleSearchChange.bind(this);
    this.handleKeyPress = this.handleKeyPress.bind(this);
    this.handleValidSubmit = this.handleValidSubmit.bind(this);

    // component state
    this.state = {
      searchText: '',
    };
  }

  // update state as search value changes
  handleSearchChange(e) {
    this.setState({ searchText: e.target.value });
  }

  // catch enter clicks
  handleKeyPress(target) {
    if (target.charCode === 13) {
      this.handleValidSubmit();
    }
  }

  // Handle submission once all form data is valid
  handleValidSubmit() {
    const { searchAlbumsFunction } = this.props;
    const formData = this.state;
    searchAlbumsFunction(formData.searchText);
  }

  render() {
    const { albums } = this.props;
    return (
      <div>
        <div className="row justify-content-center">
          <div className="col-10 col-sm-7 col-md-5 col-lg-4">
            <AvForm onValidSubmit={this.handleValidSubmit}>
              <AvGroup>
                <h2><Label for="search">Search Albums</Label></h2>
                <p>
                  Find albums you own and add them to your MusicList.
                  You can search by album title or artist name.
                </p>
                <AvInput
                  id="search"
                  name="search"
                  onChange={this.handleSearchChange}
                  onKeyPress={this.handleKeyPress}
                  placeholder="Queens of the Stone Age"
                  required
                  type="text"
                  value={this.state.searchText}
                />
                <AvFeedback>Required</AvFeedback>
              </AvGroup>
              <Button color="primary">Search Albums</Button>
            </AvForm>
          </div>
        </div>
        <div className="row">
          <div className="col-12 col-sm-12">
            { albums && albums.length > 0 ? <div><hr /><h2>Albums</h2></div> : null }
            { albums && albums.length > 0 ? listAlbums(albums) : null }
          </div>
        </div>
      </div>
    );
  }
}

Most of this should be super-familiar, but let’s take a look at two things. First is our listAlbums helper on line 6. This takes an array of albums, then loops through it using Array.map to spit back out JSX for each album in the array. This is wildly handy, and we’ll be using similar helpers all over the place. Notice that it’s using not one but two ES6 implicit returns. If you need a refresher on those, we talked about them in depth in Tutorial 41.

The other thing to note is lines 80 and 81 in which we use some simple ternary operators to determine whether the albums list is populated and, if it is, add a bit of text and call our helper function.

All right, last thing: we need to add this new page to our Template file for routing. So save this file, and open up /src/components/Template.jsx. Below line 3, add this code:

import AlbumsPage from './albums/AlbumsPageContainer';

And below line 29, add this:

          <Route exact path="/albums" component={AlbumsPage} />

Save the file and you’re done. That’s a whole bunch of stuff. The API endpoint, actions and reducers, and a page to control them all. Let’s check in a browser and make sure this thing works. Restart your server for safety, and when it’s ready, refresh your site. Then head for localhost:3000/albums. You should see your search box, awaiting input. Type in the name of your favorite band. Unless they’re a J-Pop/Ska fusion band from Norway, Discogs will probably have an album of theirs in the DB. Actually, they still might! You should see a really ugly list of albums pop up. Nice! Next step: making that list a bit prettier, and a lot more functional. See you then!

« Previous Tutorial Next Tutorial »