« Previous Tutorial Next Tutorial »

Welcome back. We’ve got another tutorial with an absolute ton of code in it, today. We’re going to create a page that shows user’s lists of liked artists and owned albums, and write all of the API endpoints, actions, and reducers to populate it. In the process, we’re going to cover a tricky issue that can happen with asynchronous actions, and how to deal with it. Let’s get started.

First, open up /routes/api/users.js. Add a padding line below line 5, and then we’re going to create a lookup routine which will return a username and any album and artist IDs associated with it. Here’s the code:

// POST to /find
router.post('/find', (req, res, next) => {
  // Get the requested user
  User.findOne({ username: req.body.username }, (err, user) => {
    if (err) {
      return res.json({ error: err });
    }
    if (!user) {
      return res.json({ error: 'Username not found' });
    }
    const { username, albums, artists } = user;
    return res.json({ username, albums, artists });
  });
});

That’s all we need here. Save the file and then open /routes/api/albums.js. If you have an accidental padding line after line 141, delete that, which will make ESLint stop complaining. Then, below that final closing brace (now on line 142), add a padding line, and this code:

// POST to /populate
router.post('/populate', (req, res, next) => {
  // Get album data from an array
  Album.find({
    discogsId: { $in: req.body },
  }, (err, albums) => {
    if (err) {
      return res.json({ error: err.message });
    }
    return res.json(albums);
  });
});

This will take a list of Album IDs and get the full album data for each one, then send it back as JSON. Sweet. Save this file, and let’s do the same thing for artists. Open up /routes/api/artists.js and add a padding line below line 84, followed by this code:

// GET to /populate
router.post('/populate', (req, res, next) => {
  // Get artist data from an array
  Artist.find({
    discogsId: { $in: req.body },
  }, (err, artists) => {
    if (err) {
      return res.json({ error: err.message });
    }
    return res.json(artists);
  });
});

Save the file and our API is set for now. Head for /src/actions/albums.js. Below line 10, add these two action creators:

export const albumsPopulateFailure = error => ({ type: 'MUSIC_ALBUMS_POPULATE_FAILURE', error });
export const albumsPopulateSuccess = json => ({ type: 'MUSIC_ALBUMS_POPULATE_SUCCESS', json });

Then, below line 54, add a padding line, and our populateAlbums function:

// Populate Album data
export function populateAlbums(albums) {
  return async (dispatch) => {
    // clear the error box if it's displayed
    dispatch(clearError());

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

    // Hit the API
    await fetch(
      '/api/albums/populate',
      {
        method: 'POST',
        body: JSON.stringify(albums),
        headers: {
          'Content-Type': 'application/json',
        },
        credentials: 'same-origin',
      },
    )
    .then((response) => {
      if (response.status === 200) {
        return response.json();
      }
      return null;
    })
    .then((json) => {
      if (!json.error) {
        return dispatch(albumsPopulateSuccess(json));
      }
      return dispatch(albumsPopulateFailure(new Error(json.error)));
    })
    .catch(error => dispatch(albumsPopulateFailure(new Error(error))));

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

This hits the API route we just set up that returns full album data. We need to do the same thing for artists, so save this file and open /src/actions/artists.js. Once again, below line 10, add two action creators:

export const artistsPopulateFailure = error => ({ type: 'MUSIC_ARTISTS_POPULATE_FAILURE', error });
export const artistsPopulateSuccess = json => ({ type: 'MUSIC_ARTISTS_POPULATE_SUCCESS', json });

And below line 54, add a padding line, and this code:

// Populate Artist data
export function populateArtists(artists) {
  return async (dispatch) => {
    // clear the error box if it's displayed
    dispatch(clearError());

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

    // Hit the API
    await fetch(
      '/api/artists/populate',
      {
        method: 'POST',
        body: JSON.stringify(artists),
        headers: {
          'Content-Type': 'application/json',
        },
        credentials: 'same-origin',
      },
    )
    .then((response) => {
      if (response.status === 200) {
        return response.json();
      }
      return null;
    })
    .then((json) => {
      if (!json.error) {
        return dispatch(artistsPopulateSuccess(json));
      }
      return dispatch(artistsPopulateFailure(new Error(json.error)));
    })
    .catch(error => dispatch(artistsPopulateFailure(new Error(error))));

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

Save the file, and create a new file in /src/actions called users.js. We’re going to use this to do a user lookup, and to clear the user’s list whenever that component unmounts. Here’s the code in its entirety:

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

// Action Creators
export const userClearList = () => ({ type: 'USER_CLEAR_LIST' });
export const userLookupFailure = error => ({ type: 'USER_LOOKUP_FAILURE', error });
export const userLookupSuccess = json => ({ type: 'USER_LOOKUP_SUCCESS', json });

// Look up a user
export function userLookup(username) {
  return async (dispatch) => {
    // clear the error box if it's displayed
    dispatch(clearError());

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

    // API call
    await fetch(
      '/api/users/find',
      {
        method: 'POST',
        body: JSON.stringify({ username }),
        headers: {
          'Content-Type': 'application/json',
        },
        credentials: 'same-origin',
      },
    )
    .then((response) => {
      if (response.status === 200) {
        return response.json();
      }
      return null;
    })
    .then((json) => {
      if (json.username) {
        return dispatch(userLookupSuccess(json));
      }
      return dispatch(userLookupFailure(new Error(json.error)));
    })
    .catch(error => dispatch(userLookupFailure(new Error(error))));

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

This stuff should all be old hat for you at this point! Save this file, and let’s make some reducers. In fact, let’s start with a brand new “list” reducer. In /src/reducers create a file called list.js and add this code to it:

const initialState = {
  username: '',
  albums: [],
  albumsPopulated: [],
  artists: [],
  artistsPopulated: [],
};

export default function reducer(state = initialState, action) {
  switch (action.type) {
    case 'MUSIC_ALBUMS_POPULATE_FAILURE': {
      const newState = Object.assign({}, state);
      newState.albumsPopulated = [];
      return newState;
    }
    case 'MUSIC_ALBUMS_POPULATE_SUCCESS': {
      const newState = Object.assign({}, state);
      newState.albumsPopulated = action.json;
      return newState;
    }
    case 'MUSIC_ARTISTS_POPULATE_FAILURE': {
      const newState = Object.assign({}, state);
      newState.artistsPopulated = [];
      return newState;
    }
    case 'MUSIC_ARTISTS_POPULATE_SUCCESS': {
      const newState = Object.assign({}, state);
      newState.artistsPopulated = action.json;
      return newState;
    }
    case 'USER_CLEAR_LIST':
    case 'USER_LOOKUP_FAILURE': {
      const newState = Object.assign({}, initialState);
      return newState;
    }
    case 'USER_LOOKUP_SUCCESS': {
      const newState = Object.assign({}, state);
      newState.username = action.json.username;
      newState.albums = action.json.albums;
      newState.artists = action.json.artists;
      return newState;
    }
    default: {
      return state;
    }
  }
}

Just to keep things clear: albums and artists will contain simply an array of Discogs IDs. albumsPopulated and artistsPopulated will contain the full album and artist data that we look up via our API, using those IDs.

Save this file, and open up /src/reducers/error.js. Make sure your big list of cases starting on line 8 is alphabetized, and then under line 14 add this case:

    case 'MUSIC_ALBUMS_POPULATE_FAILURE':

Then remove the opening brace from line 17 and, below it, add these two lines:

    case 'MUSIC_ARTISTS_POPULATE_FAILURE':
    case 'USER_LOOKUP_FAILURE': {

Save this file and move on to /src/reducers/index.js. Under line 5, import our new reducer like this:

import ListReducer from '../reducers/list';

And under line 14, include it in the Store by adding this line:

  list: ListReducer,

Save that file, and we’re headed into the home stretch. Time to build some components! First off, in /src/components, create a new folder called list. Within that folder, create a file called ListPageContainer.jsx. Here are our imports and the opening of the class:

import React from 'react';
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import { populateAlbums } from '../../actions/albums';
import { populateArtists } from '../../actions/artists';
import { userClearList, userLookup } from '../../actions/users';

import ListPage from './ListPage';

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

    this.state = {
      albumsChecked: false,
      artistsChecked: false,
    };
  }

I’ll explain why we need those values in the component state in a minute. First, let’s add three lifecycle methods. Here they are:

  componentWillMount() {
    // Before the component mounts, look up the user
    const { match, userLookupFunction } = this.props;
    userLookupFunction(match.params.username);
  }

  componentWillReceiveProps(nextProps) {
    const { populateAlbumsFunction, populateArtistsFunction } = this.props;
    const { list } = nextProps;
    if (list.username !== '' && !this.state.albumsChecked) {
      populateAlbumsFunction(list.albums);
      this.setState({ albumsChecked: true });
    }
    if (list.username !== '' && !this.state.artistsChecked) {
      this.setState({ artistsChecked: true });
      populateArtistsFunction(list.artists);
    }
  }

  componentWillUnmount() {
    const { userClearListFunction } = this.props;
    userClearListFunction();
  }

OK, let’s talk about what’s happening here. Before the component mounts, it fires off a user lookup based on the URL (for example, musiclist.com/list/captaincode). This action is asynchronous, though, so it’s possible for the component to mount before it’s completed. That’s OK … we address that in the render block.

Next up, we’re watching for certain props as they become available. Remember, this gets triggered every single time the component receives any new props, which means if you do anything in this block that modifies props without any safety checks, then congrats … you just created an endless loop that will gobble memory until the browser crashes! Good times.

So don’t do that. Instead, we check to make sure that the user lookup has finished, which means we have a list of their albums and artists. Then we run the populate function for each, but only once, because we’re setting those component state values to true, which means we won’t re-run those functions the next time this component receives props (which would happen when those functions returned a value and the associated actions fired off). This solves our looping problem and allows us to proceed with rendering.

Finally, when the component unmounts, we nuke the data from the Store, because there’s no real reason to keep it in memory.

Let’s render. Here’s the code, including closing the class:

  render() {
    const { list } = this.props;
    if (list.username === '') {
      return (<p />);
    }

    return (
      <ListPage
        username={list.username}
        albums={list.albumsPopulated}
        artists={list.artistsPopulated}
      />
    );
  }
}

Note that we avoid rendering the page until we’ve populated the user, which means the user won’t get a header that says ’s Profile for a split second before the data’s returned. We don’t worry about waiting to populate the albums and artists, because it’s OK if those pop in as they complete.

Now we need to map both state and dispatch to props, so add a padding line below the class (which should end on line 58), and then the following code:

const mapDispatchToProps = dispatch => bindActionCreators({
  populateAlbumsFunction: populateAlbums,
  populateArtistsFunction: populateArtists,
  userClearListFunction: userClearList,
  userLookupFunction: userLookup,
  dispatch,
}, dispatch);

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

export default connect(mapStateToProps, mapDispatchToProps)(ListPageContainer);

Save this file and let’s build some simple display routines. Create a file in /src/components/list called ListPage.jsx, and add the following code:

import React from 'react';

const formatAlbums = albums => albums.map(album => (
  <p key={album.discogsId}>
    <em>{album.title}</em> by
    {album.artists[0].name}
  </p>
));
const formatArtists = artists => artists.map(artist => <p key={artist.discogsId}>{artist.name}</p>);

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

  render() {
    const { username, albums, artists } = this.props;
    return (
      <div className="row">
        <div className="col-12 col-sm-12">
          <h2>{ username }‘s Profile</h2>
          <h3>Artists They Like</h3>
          <div>
            { artists.length > 0 ? formatArtists(artists) : null }
          </div>
          <h3>Albums They Own</h3>
          <div>
            { albums.length > 0 ? formatAlbums(albums) : null }
          </div>
        </div>
      </div>
    );
  }
}

You can ignore the “useless class” and “unnecessary constructor” errors. We’re going to add a lot more formatting and functionality in the next tutorial, but this will get the job done for now. We’re just printing out album and artist names today.

Save this file and open /src/components/Template.jsx. Under line 8, add this code:

import ListPage from './list/ListPageContainer';

and under line 33, add this:

          <Route path="/list/:username" component={ListPage} />

You’re done. Save the file and head for a browser. Here are the steps for testing:

  1. Make sure you’re logged in
  2. Make sure you’ve added at least one artist and one album to your profile
  3. Navigate to localhost:3000/list/yourusername
  4. Witness the glory of your list!
  5. Navigate to localhost:3000/list/ausernamethatdoesn’texist
  6. Witness the glory of your error message!

That’s it for this tutorial. Next time, we’ll pretty things up, and also identify if a user is viewing their own list. If so, we’ll let them remove artists and albums. See you there!

« Previous Tutorial Next Tutorial »