Leo Bell
Leo Bell

Reputation: 31

Add/remove Tracks to a Playlist using React

I am trying to implement a process for adding or removing a song from a search results track list to the user's custom playlist.

I have added methods to App.js called addTrack and removeTrack that adds / removes a song to the playlist state. The application passes the method through a series of components to Track. The user can trigger the .addTrack() or .removeTrack() method by clicking the + or - sign from the search results list.

My issue is I do not know how to connect the anchor tag where I have my + and - to actually adding or removing the song from the custom playlist!

Any help you can give is greatly appreciate. Please see my code for App.js, SearchResults.js and Track below:

App.js

import React, { Component } from 'react';
import {Playlist} from '../Playlist/Playlist.js';
import './App.css';
import {SearchBar} from '../SearchBar/SearchBar.js';
import {SearchResults} from '../SearchResults/SearchResults.js';



  const playlistName ="new playlist";
  const playlistTracks = [{name:'biggie',artist:'biggiesmalls',album:'reaady to die'},{name:'nas',artist:'nases',album:'illmatic'},{name:'eminem',artist:'em',album:'marshall mathers'}];


class App extends Component {

  constructor(props){
    super(props);
    this.state.searchResults = [{name:'biggiesearch',artist:'biggiesmallssearch',album:'reaady to diesearch'},{name:'nassearch',artist:'nasessearch',album:'illmaticsearch'},{name:'eminemsearch',artist:'emsearch',album:'marshall matherssearch'}]
    this.addTrack = this.addTrack.bind(this);
    this.removeTrack = this.removeTrack.bind(this);
  }

  addTrack(track){
    if(track.id !== this.state.playlistTracks){
    this.state.playlistTracks = this.state.playlistTracks.push(track);
    }
  }

  removeTrack(track){
    this.state.playlistTracks = this.state.playlistTracks.filter(track => track.id);
    //this may be wrong! (point 49 in checklist)
  }

  render() {
    return (
      <div>
        <h1>Ja<span className="highlight">mmm</span>ing</h1>
          <div className="App">
          <SearchBar onAdd={this.addTrack}/>
            <div className="App-playlist">
          <SearchResults searchResults={this.state.searchResults}/>
          <Playlist 
              playlistName={this.state.playlistName} 
              playlistTracks={this.state.playlistTracks} 
              onRemove={this.state.removeTrack}/>
            </div>
          </div>
      </div>
    );
  }
}

export default App;

Track.js

import React, { Component } from 'react';
import './Track.css';

export class Track extends Component {

  constructor(props){
    super(props);
    this.addTrack = this.addTrack.bind(this);
    this.removeTrack = this.removeTrack.bind(this);
  }

  renderAction(){
    //className = track-action
    if(Track.isRemoval === true){
      return '+'
    }
    else{
      return '-'
    }
  }


  render() {
    return (
      <div className="Track">
          <div className="Track-information">
                <h3>{this.state.track.name}</h3>
                <p>{this.state.track.artist} | {this.state.track.album}</p>
          </div>
        <a className="Track-action" >{Track.renderAction}</a>
      </div>
    );
  }
}

export default Track;

SearchResults.js

import React, { Component } from 'react';
import './SearchResults.css';
import {TrackList} from '../TrackList/TrackList.js';

export class SearchResults extends Component {
  render() {
    return (
      <div className="SearchResults">
        <h2>Results</h2>
        <TrackList tracks={this.props.searchResults} onAdd={this.props.onAdd}/>
      </div>
    );
  }
}

export default SearchResults;

Tracklist

import React, { Component } from 'react';
import './TrackList.css';
import {Track} from '../Track/Track.js';
//  const tracks = ['firstTrack','secondTrack','thirdTrack'];

export class TrackList extends Component {

  render() {
    return (
      <div className="TrackList">
        <Track 
           track={this.props.track} 
           onAdd={this.props.onAdd(this.props.track)} 
           onRemove={this.props.onRemove(this.props.track)}/>


             const trackItems = {this.props.tracks}.map((track) =>
                <li key={this.props.track.id}>
                  track
                    {this.props.track.name};
                    {this.props.track.artist};
                    {this.props.track.album};
                </li>
              );
     </div>
    );
  }
}

export default TrackList;

Upvotes: 2

Views: 2553

Answers (2)

Jackson
Jackson

Reputation: 3518

You need to use this.setState() for the tracks to be added in your addTrack() function.

addTrack(track){
    let tracks = this.state.playlistTracks;
    let trackExists = tracks.filter(t=>{return t.id==track.id}); //Check if already exists in array
    if(trackExists.length === 0){
        this.setState({
            playlistTracks: this.state.playlistTracks.push(track)
        });
    }
}

Upvotes: 0

AWolf
AWolf

Reputation: 8980

You could solve your use-case on at least two ways:

  1. Parent and child components where the child will trigger a callback of the parent. (This is your approach of your snippets.) See demo or fiddle below.
  2. Redux to manage the playlist as part of your application state.

To 1. Parent/Child components

It's like you've tried it in your code. And I think the only problem in your code is how your working with the state and the data structure behind it. Never modify state directly. e.g. this.state.playlist.push(...) is incorrect.

The only position where you can write this.state = { } is in a constructor. Later you have to always create a new state object with some changes to the previous state and use this.setState({...}).

With this approach you should have your playlist that you're modifying in the parent component's state and the callbacks will create a new state based on the parameter you're passing.

The callbacks are used to connect the child component with the parent. The context/scope inside them is set to the App component - so this.setState(...) will modify the state of App component. It's done with .bind(this) inside the constructor.

To 2. Redux

So I'll give you some info to my demo code that is using Redux (see below demo or fiddle). But also check the docs (Redux & React-Redux) for more details as I can only give you an overview here:

Bootstrapping your app

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

We're using the Provider component to wrap our App as children into it so we don't have to manually pass the store around. This is handled by React-Redux for us. The store is available as context in every component that's inside the Provider - even nested components.

You usually don't use the context directly because React-Redux has a helper that you can and should use. It's called connect. With it as it is already saying you can connect your component to Redux store and also map your state & methods to your component properties.

React-Redux's connect is doing a lot of stuff for you e.g. subscribing the playlist prop to watch store changes and triggers a component re-render on data change. You can try to add the same behavior with-out connect so you can learn more about it - see commented parts in demo.

Root reducer

To get started with reducers it is OK to start with just one reducer. Later you can combine mulitple reducers e.g. you could create a playlistReducer.

What is a reducer doing? It's a pure javascript function that is getting an action and will return the next state based on the action and the previous state. Always remember to create a new state and don't modify the state directly. The action will usually contain an type and some payload.

Pure function means that it will always create the same state if you're passing the same data to it and the initial state is the same. So avoid ajax requests inside a reducer. A reducer needs to be free of side-effect.

Create the Redux store

Pass the rootReducer to createStore from Redux. It will return the store object that you're passing to the provider mentioned above.

Create an AppContainer Component

It's needed to connect the Redux state and dispatchers to props of your app component.

Final thoughts / recommendation

I would use Redux as playlist managing can quickly become more complicated e.g. multiple lists etc. If you're only having one playlist it's also OK to do it with parent / child approach.

Please have a look at the demos below or the following fiddles:

Parent / child demo code:

const log = (val) => JSON.stringify(val, null, 2);

const Track = (props) => {

	const displayIcon = (type) => {
  	const adding = type === 'add';
  	return (
  		<span title={adding? 'add track' : 'remove track'}>{adding ? '+' : '-'}</span>
  	)
  };
  
	return (
  	<span onClick={() => props.clickHandler(props.track)}>
      {displayIcon(props.type)} {props.track.title ||props.track.name} - {props.track.artist}
    </span>
  )
}

const Tracklist = (props) => {
	const listType = props.listType;
	return (
  	<div>
      {props.playlist.length === 0 ?
        ( <strong>No tracks in list yet.</strong> ) :
        ( <ul>
            
          { props.playlist.map((track) => 
          	(
              <li key={track.id} >
                { listType === 'playlist' ? 
                  <Track 
                    clickHandler={props.clickHandler} 
                    type="remove" 
                    track={track} /> : 
                  <Track 
                    clickHandler={props.clickHandler} 
                    type="add" 
                    track={track} /> }
                <span>{props.isInList && props.isInList(track) ? 
                ' - added' : null
                }
                </span>
              </li>
            )
          )}
        </ul> )
      }
    </div>
  )
}

const initialState = {
	playlist: [{
  	id: 0,
    title:'Black or White',
    artist: 'Michael Jackson'
  },
  {
  	id: 1,
    title:'Bad',
    artist: 'Michael Jackson'
  },
  ]
};

const searchData = {
    	playlist: [
      	{id: 's1', name:'biggiesearch',artist:'biggiesmallssearch',album:'reaady to diesearch'},				
        {id: 's2', name:'nassearch',artist:'nasessearch',album:'illmaticsearch'},		
      	{id: 's3', name:'eminemsearch',artist:'emsearch',album:'marshall matherssearch'}
      ]
};

class App extends React.Component {
	constructor(props) {
  	super(props);
    this.state = initialState;
    
    this.add = this.add.bind(this);
    this.remove = this.remove.bind(this);
    this.isInList = this.isInList.bind(this);
  }
  
	render () {
  	return (
    	<div>
       <h1>Playlist:</h1>
    	  <Tracklist 
    	    listType="playlist" 
    	    playlist={this.state.playlist} 
    	    clickHandler={this.remove}
          />
        <h1>Search result:</h1>
        <Tracklist 
          playlist={searchData.playlist}
          clickHandler={this.add} 
          isInList={this.isInList}
          />
          <pre>{log(this.state)}</pre>
       </div>
    )
  }
  
  add (newTrack) {
  	console.log('Add track', newTrack);
    if (this.state.playlist.filter(track => track.id === newTrack.id).length === 0) {
      this.setState({
        ...this.state,
				playlist: [
        	...this.state.playlist,
          newTrack
        ]
      });
    }
  }
  
  isInList (track) {
  	// used for displayling if search result track is already in playlist
  	return this.state.playlist.filter(playlistTrack => 
    	playlistTrack.id === track.id).length > 0;
  }
  
  remove (trackToRemove) {
  	console.log('remove', trackToRemove);
    this.setState({
    	...this.state,
      playlist: this.state.playlist.filter(track => track.id !== trackToRemove.id)
    });
  }
}

ReactDOM.render(
	<App/>,
  document.getElementById('root')
)
* {
  font-family: sans-serif;
}

h1 {
  font-size: 1.2em;
}

li {
  list-style-type: none;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.0.0/umd/react.development.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.0.0/umd/react-dom.development.js"></script>
<div id="root"></div>

Redux demo

const TrackList = (props) => (
	<div>
	  {props.tracks.map((track) => (
    	<div key={track.id}>{track.title || track.name} - {track.artist} 
      {props.onAddClick ? 
      	( <button onClick={() => props.onAddClick(track)}>Add</button> ) : null }
      {props.onRemoveClick ?
      	( <button onClick={() => props.onRemoveClick(track.id)}>Remove</button> ): null
      }
      </div>
    ))}
    
    {props.tracks.length === 0 ? 
    	( <strong>No tracks.</strong> ) : null
    }
	</div>
)
/*
const App = ({playlist, add, remove}) => (
	<div>
	  <h2>Current playlist</h2>
    <TrackList tracks={playlist} onRemoveClick={remove}></TrackList>
    <SearchResult onAddClick={add}></SearchResult>
	</div>
)*/

class App extends React.Component {
	
  render () {
  	/*
    // the following code would be required if connect from react-redux is not used
    // -> subscribe to state change and update component
    // So always use React-redux's connect to simplify code
    
    const store = this.context.store;
    console.log(this.props);
    
    const select = (state) => state.playlist;
    let playlist = select(store.getState());
    
    function handleChange() {
      let previousValue = playlist
      playlist = select(store.getState())

      if (previousValue !== playlist) {
        console.log('playlist changed');
        // re-render
        this.forceUpdate();
      }
    }
    
    this.unsubscribe = store.subscribe(handleChange.bind(this));
    // --> also unsubscribing in unmount would be required
    */
    
    console.log('playlist render', this.props);
  	return (
    	<div>
	  <h2>Current playlist</h2>
    <TrackList tracks={this.props.playlist} onRemoveClick={this.props.remove}></TrackList>
    <SearchResult onAddClick={this.props.add}></SearchResult>
      
      <hr/>
      <pre>
        debugging
        {JSON.stringify(store.getState(), null, 2)}  
      </pre>
	</div>
    )
  }
}

console.log('react', PropTypes.object);
App.contextTypes = {
	store: PropTypes.object
}

class SearchResult extends React.Component {
	
  constructor(props) {
  	super(props);
    
    this.searchResults = // this will be a result of a search later
    	[
      	{id: 's1', name:'biggiesearch',artist:'biggiesmallssearch',album:'reaady to diesearch'},				
        {id: 's2', name:'nassearch',artist:'nasessearch',album:'illmaticsearch'},		
      	{id: 's3', name:'eminemsearch',artist:'emsearch',album:'marshall matherssearch'}
      ];
  }
  
  render () {
 		return (
    	<div>
    	  <h2>Search results: </h2>
    	  <TrackList tracks={this.searchResults} onAddClick={this.props.onAddClick}>
    	  </TrackList>
      </div>
    ) 	
  }
}

const initialState = {
	playlist: [{
  	id: 0,
    title:'Michal Jackson',
    artist: 'Black or White'
  },
  {
  	id: 1,
    title:'Michal Jackson',
    artist: 'Bad'
  },
  ]
}
const rootReducer = (state = initialState, action) => {
	const newTrack = action.track;
	switch(action.type) {
  	case 'ADD':
    	// only add once
      if (state.playlist.filter(track => action.track.id === track.id).length > 0) {
      	return state; // do nothing --> already in list 
      }
      
  		return {
      	...state,
        playlist: [
          ...state.playlist,
          {
            id: state.playlist.length,
            ...newTrack
          }
        ]
      };
    case 'REMOVE':
    	 console.log('remove', action.id)
       return {
          ...state,
          playlist: state.playlist.filter((track) => track.id !== action.id)
       };
    default:
    	return state;
  }
}

const store = Redux.createStore(rootReducer)

const Provider = ReactRedux.Provider

// actions
const addToList = (track) => {
	return {
  	type: 'ADD',
  	track
  };
}

const removeFromList = id => {
	return {
  	type: 'REMOVE',
  	id
  };
}

const AppContainer = ReactRedux.connect(
	// null // --> set to null if you're not using mapStateToProps --> manually handling state changes required (see comment in App)
  (state) => { return { playlist: state.playlist } } // mapStateToProps
  ,
  (dispatch) => {
  	return {
      add: track => dispatch(addToList(track)),  // mapDispatchToProps
      remove: id => dispatch(removeFromList(id)) 
  	}
  }
)(App)

console.log(Provider);

ReactDOM.render(
	<Provider store={store}>
    <AppContainer />
  </Provider>
, document.getElementById('root'));
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.0.0/umd/react.development.js"></script>
<script src="https://unpkg.com/prop-types/prop-types.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-redux/5.0.6/react-redux.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.0.0/umd/react-dom.development.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/redux/3.7.2/redux.js"></script>

<div id="root">

</div>

Upvotes: 3

Related Questions