Step 5 - Refs, Component API & Lifecycle
References:
Component API
In the last steps we learned components have state and props. Now we’re going to study how the Components react (pun intended) to their changes.
Back to our app, we are displaying 12 of the 200 products we generated. We could display all 200, but that could take a long time to load and it would be quite hard to keep track of them when browsing. What if we could do something awesome like… pagination?
We can and we will.
The setState
method
Let’s start with creating a new component (src/client/app/components/common/ListPagination.jsx
):
import React from 'react';
import { Pagination } from 'react-bootstrap';
function ListPagination(props) {
return (
<Pagination
activePage={props.activePage}
first
items={props.items}
last
maxButtons={5}
next
onSelect={props.onSelect}
prev />
);
}
ListPagination.propTypes = {
activePage: React.PropTypes.number.isRequired,
items: React.PropTypes.number.isRequired,
onSelect: React.PropTypes.func.isRequired
};
ListPagination.defaultProps = {
activePage: 1,
items: 1,
onSelect: () => {}
};
export default ListPagination;
Again, we’re using the Pagination Component from react-bootstrap, which saves us a lot of time (imagine creating a component which dynamically renders a number of buttons, setting the ‘active’ class to a specified button number, limiting the number of buttons to display, etc. Not extremely hard but non-trivial and totally beyond the purpose of this tutorial).
What our Component does is it just sets some default parameters for the react-bootstrap Pagination Component.
Now let’s set the App state’s products back to our full list (App.jsx
):
...
this.state = {
products
};
...
Now let’s do a small change to our .eslintrc
so it won’t bug us for using setState
(as it’s a valid use-case here) or non-alphabetically sorted props:
...
"react/jsx-sort-props": 0,
...
"react/no-set-state": 0,
...
"react/sort-prop-types": 0,
...
then change the ProductList.jsx
file to:
import React from 'react';
import Product from '../components/Product.jsx';
import ListPagination from './common/ListPagination.jsx';
import { Col, Clearfix } from 'react-bootstrap';
const PAGE_SIZE = 24;
class ProductList extends React.Component {
constructor(props) {
super(props);
this.state = {
activePage: 1
};
this.handleSelectPage = this.handleSelectPage.bind(this);
}
handleSelectPage(newPage) {
this.setState({
activePage: newPage
});
}
render() {
const {
activePage
} = this.state;
const products = this.props.products.slice((activePage - 1) * PAGE_SIZE, activePage * PAGE_SIZE);
return (
<div>
<Col
className="text-right"
xs={12}>
<ListPagination
activePage={activePage}
items={Math.ceil(this.props.products.length / PAGE_SIZE)}
onSelect={this.handleSelectPage} />
</Col>
<Clearfix />
{
products.map(product => (
<Product
key={product.id}
product={product} />
))
}
</div>
);
}
}
ProductList.propTypes = {
products: React.PropTypes.arrayOf(React.PropTypes.object).isRequired
};
ProductList.defaultProps = {
products: []
};
export default ProductList;
Let’s analyse our changes:
1. We imported the ListPagination
Component
2. We imported the Clearfix
Component from react-bootstrap
3. We set a PAGE_SIZE constant to 24
4. We set our initial state to { activePage: 1 } so that the first page displayed is the first page
5. We created a handleSelectPage
method which makes use of setState
to update the activePage
to the one we pass it, and we bound this
to it in the constructor
(React.createClass()
does this automatically, we’ll discuss it later when handling events)
6. We created a products
array which is a slice of PAGE_SIZE (24) products from our main array; this is the products list we display
7. We passed the activePage, pages number (all products’ length / PAGE_SIZE) and the handleSelectPage
method as props to the ListPagination
Component
Now when we click on the pagination buttons, the handleSelectPage
method is called with the page number as argument, which updates our list and pagination Component as expected. Cool, isn’t it?
Refs & the ReactDOM.findDOMNode
method
React supports a special attribute that you can attach to any component. The ref attribute can be a callback function, and this callback will be executed immediately after the component is mounted. The referenced component will be passed in as a parameter, and the callback function may use the component immediately, or save the reference for future use (or both). – React Docs
To demonstrate how forceUpdate
works, we’re going to do something wrong (we’ll fix it later). Let’s create a ProductPage
Component and replace the ProductList
with it:
src/client/app/components/ProductPage.jsx
import React from 'react';
import { findDOMNode } from 'react-dom';
import { PageHeader, Image, Col, Panel, Media, InputGroup, FormControl, Button } from 'react-bootstrap';
class ProductPage extends React.Component {
constructor(props) {
super(props);
// this.state = {
// quantity
// }
}
componentWillMount() {
console.log('Component WILL MOUNT!');
const qtyInput = document.getElementById('qtyInput');
console.log(qtyInput);
}
componentDidMount() {
console.log('Component DID MOUNT!');
const qtyInput = findDOMNode(this.qtyInput);
console.log(qtyInput);
qtyInput.onchange = (e) => {
this.quantity = e.target.value;
console.log(this.quantity);
this.forceUpdate();
};
}
componentWillReceiveProps(/*newProps*/) {
console.log('Component WILL RECIEVE PROPS!');
}
shouldComponentUpdate(/*newProps, newState*/) {
return true;
}
componentWillUpdate(/*nextProps, nextState*/) {
console.log('Component WILL UPDATE!');
}
componentDidUpdate(/*prevProps, prevState*/) {
console.log('Component DID UPDATE!');
}
componentWillUnmount() {
console.log('Component WILL UNMOUNT!');
}
render() {
const { product } = this.props;
const PanelHeader = (
<div className="text-uppercase">
Price:
<span className="pull-right">
{`${product.price}$`}
</span>
</div>
);
const PanelFooter = (
<div>
<InputGroup>
<FormControl
id="qtyInput"
min="1"
max="99"
type="number"
defaultValue={1}
ref={(ref) => { this.qtyInput = ref; }} />
<InputGroup.Addon>
Units
</InputGroup.Addon>
</InputGroup>
<div className="text-uppercase h4 product-page-total">
Total:
<span className="pull-right">
{
`${
Number(
this.quantity ?
product.price * this.quantity :
product.price
).toFixed(2)
} $`
}
</span>
</div>
<Button
block
bsStyle="primary"
bsSize="large">
Add to Cart
</Button>
</div>
);
return (
<div>
<Col sm={12}>
<PageHeader>
{product.name}
</PageHeader>
<Media className="product-page-brand">
<Media.Left align="middle">
<Image
alt={product.brand_name}
circle
className="product-page-brand-logo"
height="49"
src={product.brand_logo} />
</Media.Left>
<Media.Body>
<Media.Heading className="product-page-brand-name">{product.brand_name}</Media.Heading>
</Media.Body>
</Media>
</Col>
<Col sm={8}>
<div className="product-page-img">
<Image
alt={product.name}
className="img-responsive"
src={product.picture}
thumbnail />
</div>
</Col>
<Col sm={4}>
<Panel
footer={PanelFooter}
header={PanelHeader}>
{product.description}
</Panel>
</Col>
</div>
);
}
}
ProductPage.propTypes = {
product: React.PropTypes.object.isRequired
};
export default ProductPage;
then in App.jsx
:
import ProductPage from '../components/ProductPage.jsx';
...
<Grid id="content">
<ProductPage
product={this.state.products[0]} />
</Grid>
...
and in main.less
:
/****** Product Page ******/
.product-page {
&-brand {
margin-bottom: @form-group-margin-bottom * 2;
}
&-brand-logo {
border: 1px solid @panel-default-border;
}
&-brand-name {
margin: @form-group-margin-bottom 0;
}
}
Now let’s analyse our new Component:
- We added some components to display our product’s information, pictures, description, etc. Nothing too fancy about those.
- We display a total price which is basically
product_price * quantity
- We added a number input with react-bootstrap’s
FormControl
and we attached a ref to it - We used the ref to get the underlying DOM node using the
findDOMNOde
method (as our FormControl is not a DOM node, the ref returns a React Component instance; if it was a regular DOM node, the ref would point to the DOM node itself) - We attached a change event handler to the DOM node
Now if we change the input’s value and take a look at the console we notice the value of the input is printed out… but the component is not updated (the total price stays the same). That is because React Components update when props or state change, but we attached the quantity value to the component as a property (just like we did with the ref, actually). We could have attached it to a regular variable and we’d had the same result.
To make the component update in this case, we have to call forceUpdate
. This method triggers a update, bypassing the shouldComponentUpdate
method. Add it to the change handler (and remove the console.log with this occasion):
...
qtyInput.onchange = (e) => {
this.quantity = e.target.value;
this.forceUpdate();
};
...
Now the component updates as we expect. But, again, this is the wrong way to do it. We’ll fix it in the next step but for now let’s take a look at the Component Lifecycle.
Component Lifecycle methods
I won’t go in details with the lifecycle methods, as they’re explained perfectly here.
For the sake of demonstration, we’ve added them to our component too. Some we used (componentDidMount), some we just listed with console.log
s.
We will likely use them later in this tutorial.