The Show Feature: The CatPage Component
This is Part V of a eight part series on building a CRUD application with React + Redux. You can view the code for this project here. You can view the table of contents here
We're aiming to dynamically render the details of each cat, via some sort of "show" page, next to the index list of cats. Something like this:
In order to retain the index list of cats on the left, even as we render each cat's details on the right, we'll build the CatPage component as a child of the CatsPage component.
We need to do two things to for that to happen:
- Define the
/cats:id
route as a child of the/cats
route. - Call
{this.props.children}
inside the CatsPage component'srender
function.
Defining The Route
We'll revisit our routes and add the cat's show route:
import React from 'react';
import { Route, IndexRoute } from 'react-router';
import App from './components/App';
import HomePage from './components/home/HomePage';
import CatsPage from './components/cats/CatsPage';
import CatPage from './components/cats/CatPage';
export default (
<Route path="/" component={App}>
<IndexRoute component={HomePage} />
<Route path="/cats" component={CatsPage} >
<Route path="/cats/:id" component={CatPage} />
</Route>
</Route>
);
Here, we're mapping the cats/:id
route to the CatPage
component, which we'll build in a moment. And we're making sure to import that component at the top of the file.
Calling Children
Next up, we need to tell our CatsPage container component to render its children. We'll change the render
function of our CatsPage component in the following way:
...
render() {
const cats = this.props.cats;
return (
<div className="col-md-12">
<h1>Cats</h1>
<div className="col-md-4">
<CatList cats={cats} />
</div>
<div className="col-md-8">
{this.props.children}
</div>
</div>
);
}
Now we're ready to define our CatPage Component.
Defining the Component
Our component needs to subscribe to the store, in order to receive the cats payload and find the correct cat to display. So, we'll start with our mapStateToProps
function:
// src/components/cats/CatPage.js function mapStateToProps(state, ownProps) { let cat = {name: '', breed: '', weight: '', temperament: '', hobby_ids: []}; const catId = ownProps.params.id; if (state.cats.length > 0) { cat = Object.assign({}, state.cats.find(cat => cat.id == id) } return {cat: cat}; }
Setting the cat with mapStateToProps
Our function needs to introspect on the state's collection of cats and find the correct one to pass to our component for rendering. We can identify the requested cat by looking at the ownProps
argument that gets passed to mapStateToProps
.
ownProps
represents any props passed to our component. ownProps
has an attribute, params
, which, thanks to the way we defined our route with the dynamic segment, will look like this:
ownProps.params.id
// => {id: "1"}
We can use this ID number to find and retrieve the correct cat from state.
However, we have to include the if
condition to check for the presence of any cats in state. This is because the component may render before the collection of cats is finished being retrieved from the API.
If that's the case, we don't want our component to break. We'll simply set an empty cat
placeholder object until the state changes to include the cats payload. When that happens, it will trigger the mapStateToProps
function to run again, followed by a re-render of our component.
Now that we are successfully grabbing the right cat and passing it to the component as props, let's build out our render
function.
import React, {PropTypes} from 'react';
import {connect} from 'react-redux';
class CatPage extends React.Component {
render() {
return (
<div className="col-md-8 col-md-offset-2">
<h1>{this.props.cat.name}</h1>
<p>breed: {this.props.cat.breed}</p>
<p>weight: {this.props.cat.weight}</p>
<p>temperament: {this.props.cat.temperament}</p>
</div>
);
}
};
CatPage.propTypes = {
cat: PropTypes.object.isRequired,
};
function mapStateToProps(state, ownProps) {
let cat = {name: '', breed: '', weight: '', temperament: '',
hobby_ids: []};
const catId = ownProps.params.id;
if (state.cats.length > 0) {
cat = Object.assign({}, state.cats.find(cat => cat.id ==
id)
}
return {cat: cat};
};
export default connect(mapStateToProps)(CatPage);
So far so good. The only problem is that we're not displaying the collection of hobbies associated with the given cat. In order to do that, we need to retrieve the hobbies from our API, add them to the application's state, and pull them into the component in our mapStateToProps
function.
We'll follow the same pattern we used to load all the cats from the API to build out this functionality.
Loading Hobbies from the API
We'll start by building out or HobbyApi class.
// src/api/hobbyApi.js class HobbyApi { static getAllHobbies() { return fetch('http://localhost:5000/api/v1/hobbies').then(response => { return response.json() }).catch(error => { return error }); } }; export default HobbyApi;
Next up, we'll build out some hobby-specific actions for making this API call and dispatching a success action to the store.
// src/actions/hobbyAction.js
import * as types from './actionTypes';
import hobbyApi from '../api/HobbyApi';
export function loadHobbiesSuccess(hobbies) {
return {type: types.LOAD_HOBBIES_SUCCESS, hobbies};
}
export function loadHobbies() {
return function(dispatch) {
return hobbyApi.getAllHobbies().then(hobbies => {
dispatch(loadHobbiesSuccess(hobbies));
}).catch(error => {
throw(error);
});
};
}
We'll add the LOAD_HOBBIES_SUCCESS
action type to our list of constants:
// src/actions/actionTypes.js
export const LOAD_CATS_SUCCESS = 'LOAD_CATS_SUCCESS';
export const LOAD_HOBBIES_SUCCESS = 'LOAD_HOBBIES_SUCCESS';
Lastly, we'll build a hobbies reducer to handle the dispatch of the loadHobbiesSuccess
action.
// src/reducers/hobbyReducer.js
import * as types from '../actions/actionTypes';
import initialState from './initialState';
export default function courseReducer(state = initialState.hobbies, action) {
switch(action.type) {
case types.LOAD_HOBBIES_SUCCESS:
return action.hobbies;
default:
return state;
}
}
Next, we need to add the hobbies reducer to our root reducer:
// src/reducers/rootReducer.js
import {combineReducers} from 'redux';
import cats from './catReducer';
import hobbies from './hobbyReducer';
const rootReducer = combineReducers({
cats,
hobbies
})
export default rootReducer;
Now, let's tell the store to dispatch our loadHobbies
action when it dispatches the loadCats
action--in our app's entry point, index.js
.
/*eslint-disable import/default */
import 'babel-polyfill';
import React from 'react';
import { render } from 'react-dom';
import configureStore from './store/configureStore';
import { Provider } from 'react-redux';
import { Router, browserHistory } from 'react-router';
import routes from './routes';
import {loadCats} from './actions/catActions';
import {loadHobbies} from './actions/hobbyActions';
const store = configureStore();
store.dispatch(loadCats());
store.dispatch(loadHobbies());
render(
<Provider store={store}>
<Router history={browserHistory} routes={routes} />
</Provider>,
document.getElementById('app')
);
Great! Now the state
that get's passed to our container components will look something like this:
{cats: [some cats], hobbies: [some hobbies]}
We're ready to revisit our CatPage component and grab the cat hobbies to display!
Displaying Associated Data
In our Rails API, we serialized our data such that each cat has a property, hobby_ids
, pointing to an array of the IDs of the hobbies to which it is associated.
[
{
id: 1,
name: "Moe",
weight: "heavy",
temperament: "protective",
hobby_ids: [1, 2]
},
{
id: 2,
name: "Mini",
weight: "heavy",
temperament: "sweet",
hobby_ids: [1]
},
]
So, we can use this hobby_ids
attribute to collect the given cat's hobbies in the mapStateToProps
function of our CatPage component. We'll expand upon on mapStateToProps
function and build a helper function to help us collect the correct hobbies:
// src/components/cats/CatPage.js ... function collectCatHobbies(hobbies, cat) { let selected = hobbies.map(hobby => { if (cat.hobby_ids.filter(hobbyId => hobbyId == hobby.id).length > 0) { return hobby; } }) return selected.filter(el => el != undefined) } function mapStateToProps(state, ownProps) { let cat = {name: '', breed: '', weight: '', temperament: '', hobby_ids: []}; let Cathobbies = [] const catId = ownProps.params.id; if (state.cats.length > 0 && state.hobbies > 0) { cat = Object.assign({}, state.cats.find(cat => cat.id == id) if (cat.hobby_ids.length > 0) { hobbies = collectCatHobbies(state.hobbies, cat) } } return {cat: cat, catHobbies: catHobbies}; };
Let's update our CatPage Prop Type validations accordingly:
CatPage.propTypes = {
cat: PropTypes.object.isRequired,
catHobbies: PropTypes.array.isRequired
};
Now we can update our render
method to display the hobbies we've collected.
// src/components/cat/CatPage.js;
import HobbList from './HobbyList';
...
render() {
return (
<div className="col-md-8 col-md-offset-2">
<h1>{this.props.cat.name}</h1>
<p>breed: {this.props.cat.breed}</p>
<p>weight: {this.props.cat.weight}</p>
<p>temperament: {this.props.cat.temperament}</p>
<HobbyList hobbies={this.props.catHobbies} />
</div>
);
}
...
In line with the pattern of using container components pass data to presentation components, we'll call on a new component, HobbyList, instead of dealing with all the presentational details here.
The HobbyList Component
We'll set up our HobbyList component similarly to how we configured our CatList component earlier:
import React, {PropTypes} from 'react';
const HobbyList = ({hobbies}) => {
return (
<div>
<h3>Hobbies</h3>
<ul>
{hobbies.map(hobby =>
<li key={hobby.id}>{hobby.name}</li>
)}
</ul>
</div>
);
};
HobbyList.propTypes = {
hobbies: PropTypes.array.isRequired
};
export default HobbyList;
Now our CatPage component should be fully up and running--displaying all of a cat's details, including that cat's hobbies.