This tutorial is out of date and no longer maintained.
Welcome to the fourth and final installment of the Learning React series! Up to this point, we’ve learned how React’s API allows us to create rich stateful components, how to use them in practice & how Facebook’s Flux architecture works.
Today we are going to put all of it together to create a basic shopping cart application. In a typical e-commerce website, the product detail page has several moving parts that rely on one another, and React really helps simplify and organize the co-dependency between them.
If you haven’t already, I highly recommend checking out the first three parts of this series:
During the writing of this series, React released version 0.12 which made some fairly significant changes. This tutorial will be written in 0.12 syntax. Some of the changes include:
/** @jsx React.DOM */
header is no longer required when writing JSX syntaxrenderComponent
is now render
renderComponentToString
is now renderToString
You can check out the entire changelog on the official blog post.
Our first step to architecting an application is defining what it should do. We want to:
This is what our finished product should look like:
This app is going to be purely client-side, so we aren’t going to need a server. Instead, we will be using a mock API and mock data in order to focus on the components themselves. Let’s take a look at our directory structure:
css/
---- app.css
img/
---- scotch-beer.png
js/
---- actions/
-------- FluxCartActions.js // Our app's action creators
---- components/
-------- FluxCart.react.js // Cart Component
-------- FluxCartApp.react.js // Main Controller View
-------- FluxProduct.react.js // Product Component
---- constants/
-------- FluxCartConstants.js // Our app's action constants
---- dispatcher/
-------- AppDispatcher.js // Our app's dispatcher
---- stores/
-------- CartStore.js // Cart Store
-------- ProductStore.js // Product Store
---- utils/
-------- CartAPI.js // Mock API
---- app.js // Main app.js file
---- ProductData.js // Mock Data
index.html
package.json
Below, check out our package.json
file. We will be using the following modules:
We can run npm install
to install all of our dependencies, and then use the npm start
command to start a process that watches our project and bundles our source on save.
{
"name": "flux-pricing",
"version": "0.0.1",
"description": "Pricing component with flux",
"main": "js/app.js",
"dependencies": {
"flux": "^2.0.0",
"react": "^0.12.0",
"underscore": "^1.7.0"
},
"devDependencies": {
"browserify": "~6.2.0",
"envify": "~3.0.0",
"react": "^0.12.0",
"reactify": "^0.15",
"watchify": "~2.1.0"
},
"scripts": {
"start": "watchify -o js/bundle.js -v -d .",
"build": "browserify . | uglifyjs -cm > js/bundle.min.js"
},
"author": "Ken Wheeler",
"browserify": {
"transform": [
"reactify",
"envify"
]
}
}
In the interests of keeping us focused on Flux and React, we will be using a mock API and mock data for our product that we are going to display. That said, writing our product data and our API similarly to the way we would work with a real API adds the benefit that we could have an easier time plugging in a real API down the road if we had wanted to.
Let’s have a look at what our sample Product Data looks like:
module.exports = {
// Load Mock Product Data Into localStorage
init: function() {
localStorage.clear();
localStorage.setItem('product', JSON.stringify([
{
id: '0011001',
name: 'Scotch.io Signature Lager',
image: 'scotch-beer.png',
description: 'The finest lager money can buy. Hints of keyboard aerosol, with a whiff of iKlear wipes on the nose. If you pass out while drinking this beverage, Chris Sevilleja personally tucks you in.',
variants: [
{
sku: '123123',
type: '40oz Bottle',
price: 4.99,
inventory: 1
},
{
sku: '123124',
type: '6 Pack',
price: 12.99,
inventory: 5
},
{
sku: '1231235',
type: '30 Pack',
price: 19.99,
inventory: 3
}
]
}
]));
}
};
As you can see above, we define a product that has options called variants. Our schema mirrors the type of data you might typically get back from a call to a restful API. We go ahead and load this data into localStorage
, so that our mock API can grab that data and load it into our app.
See below how our mock API grabs our data from localStorage
and then uses Flux actions to send that data to our ProductStore:
var FluxCartActions = require('../actions/FluxCartActions');
module.exports = {
// Load mock product data from localStorage into ProductStore via Action
getProductData: function() {
var data = JSON.parse(localStorage.getItem('product'));
FluxCartActions.receiveProduct(data);
}
};
So now that we have our sample product data, and a sample API call, how do we use this to bootstrap our application with “server” data?
It’s really as simple as just initializing our data, running our API call, and then mounting our controller view. Our main app.js
file, shown below, is responsible for this process:
window.React = require('react');
var ProductData = require('./ProductData');
var CartAPI = require('./utils/CartAPI')
var FluxCartApp = require('./components/FluxCartApp.react');
// Load Mock Product Data into localStorage
ProductData.init();
// Load Mock API Call
CartAPI.getProductData();
// Render FluxCartApp Controller View
React.render(
<FluxCartApp />,
document.getElementById('flux-cart')
);
Since we are using the Flux architecture for this application, we are going to need to create our own instance of Facebook’s Dispatcher library. We also add a handleAction
helper method to our Dispatcher instance, so that we can identify where this action came from.
While this isn’t explicitly required for our current application, if we wanted to hook into a real API or handle actions from places other than views, it is nice to have this architecture in place if we needed to handle those actions differently than those that originated from views.
var Dispatcher = require('flux').Dispatcher;
// Create dispatcher instance
var AppDispatcher = new Dispatcher();
// Convenience method to handle dispatch requests
AppDispatcher.handleAction = function(action) {
this.dispatch({
source: 'VIEW_ACTION',
action: action
});
}
module.exports = AppDispatcher;
In our handleAction
method, we receive an action from an action creator and then have our Dispatcher dispatch the action with a source
property and the action that was supplied as an argument.
Now that we have our dependencies, data, and our Dispatcher set up, it’s time to start working on our project’s functional requirements. Actions are a great place to start. Let’s define some action constants in order to define which actions our app will perform:
var keyMirror = require('react/lib/keyMirror');
// Define action constants
module.exports = keyMirror({
CART_ADD: null, // Adds item to cart
CART_REMOVE: null, // Remove item from cart
CART_VISIBLE: null, // Shows or hides the cart
SET_SELECTED: null, // Selects a product option
RECEIVE_DATA: null // Loads our mock data
});
After defining our constants, we need to create their corresponding action creator methods. These are methods that we can call from our views/components that will tell our Dispatcher to broadcast the action to our Stores.
The action itself consists of an object containing our desired action constant and a data payload. Our Stores then update and fire change events, which our Controller View listens to in order to know when to begin a state update.
Below, check out how we use our Dispatcher’s handleAction
method to pass an actionType
constant and associated data to our Dispatcher:
var AppDispatcher = require('../dispatcher/AppDispatcher');
var FluxCartConstants = require('../constants/FluxCartConstants');
// Define actions object
var FluxCartActions = {
// Receive inital product data
receiveProduct: function(data) {
AppDispatcher.handleAction({
actionType: FluxCartConstants.RECEIVE_DATA,
data: data
})
},
// Set currently selected product variation
selectProduct: function(index) {
AppDispatcher.handleAction({
actionType: FluxCartConstants.SELECT_PRODUCT,
data: index
})
},
// Add item to cart
addToCart: function(sku, update) {
AppDispatcher.handleAction({
actionType: FluxCartConstants.CART_ADD,
sku: sku,
update: update
})
},
// Remove item from cart
removeFromCart: function(sku) {
AppDispatcher.handleAction({
actionType: FluxCartConstants.CART_REMOVE,
sku: sku
})
},
// Update cart visibility status
updateCartVisible: function(cartVisible) {
AppDispatcher.handleAction({
actionType: FluxCartConstants.CART_VISIBLE,
cartVisible: cartVisible
})
}
};
module.exports = FluxCartActions;
Now that we have our Actions defined, it is time to create our Stores. Each Store manages application state for a domain within an application, so we are going to create one for our product and one for our cart. Let’s start with our ProductStore
:
var AppDispatcher = require('../dispatcher/AppDispatcher');
var EventEmitter = require('events').EventEmitter;
var FluxCartConstants = require('../constants/FluxCartConstants');
var _ = require('underscore');
// Define initial data points
var _product = {}, _selected = null;
// Method to load product data from mock API
function loadProductData(data) {
_product = data[0];
_selected = data[0].variants[0];
}
// Method to set the currently selected product variation
function setSelected(index) {
_selected = _product.variants[index];
}
// Extend ProductStore with EventEmitter to add eventing capabilities
var ProductStore = _.extend({}, EventEmitter.prototype, {
// Return Product data
getProduct: function() {
return _product;
},
// Return selected Product
getSelected: function(){
return _selected;
},
// Emit Change event
emitChange: function() {
this.emit('change');
},
// Add change listener
addChangeListener: function(callback) {
this.on('change', callback);
},
// Remove change listener
removeChangeListener: function(callback) {
this.removeListener('change', callback);
}
});
// Register callback with AppDispatcher
AppDispatcher.register(function(payload) {
var action = payload.action;
var text;
switch(action.actionType) {
// Respond to RECEIVE_DATA action
case FluxCartConstants.RECEIVE_DATA:
loadProductData(action.data);
break;
// Respond to SELECT_PRODUCT action
case FluxCartConstants.SELECT_PRODUCT:
setSelected(action.data);
break;
default:
return true;
}
// If action was responded to, emit change event
ProductStore.emitChange();
return true;
});
module.exports = ProductStore;
Above, we define two private methods, loadProductData
and setSelected
. We use loadProductData
to, unsurprisingly, load our mock product data into our _product
object. Our setSelected
method is used to set which product variant is currently selected.
We expose this data using the public methods getProduct
and getSelected
, which return their respective internal objects. These methods can be called after require
’ing our Store within a view.
Lastly, we register a callback to our AppDispatcher
that uses a switch statement to determine if the supplied payload matches an action we want to respond to. In the event that it does, we call our private methods with the supplied action data, and fire a change event, forcing our view to retrieve the new state and update its display.
Next up, let’s create our CartStore
:
var AppDispatcher = require('../dispatcher/AppDispatcher');
var EventEmitter = require('events').EventEmitter;
var FluxCartConstants = require('../constants/FluxCartConstants');
var _ = require('underscore');
// Define initial data points
var _products = {}, _cartVisible = false;
// Add product to cart
function add(sku, update) {
update.quantity = sku in _products ? _products[sku].quantity + 1 : 1;
_products[sku] = _.extend({}, _products[sku], update)
}
// Set cart visibility
function setCartVisible(cartVisible) {
_cartVisible = cartVisible;
}
// Remove item from cart
function removeItem(sku) {
delete _products[sku];
}
// Extend Cart Store with EventEmitter to add eventing capabilities
var CartStore = _.extend({}, EventEmitter.prototype, {
// Return cart items
getCartItems: function() {
return _products;
},
// Return # of items in cart
getCartCount: function() {
return Object.keys(_products).length;
},
// Return cart cost total
getCartTotal: function() {
var total = 0;
for(product in _products){
if(_products.hasOwnProperty(product)){
total += _products[product].price * _products[product].quantity;
}
}
return total.toFixed(2);
},
// Return cart visibility state
getCartVisible: function() {
return _cartVisible;
},
// Emit Change event
emitChange: function() {
this.emit('change');
},
// Add change listener
addChangeListener: function(callback) {
this.on('change', callback);
},
// Remove change listener
removeChangeListener: function(callback) {
this.removeListener('change', callback);
}
});
// Register callback with AppDispatcher
AppDispatcher.register(function(payload) {
var action = payload.action;
var text;
switch(action.actionType) {
// Respond to CART_ADD action
case FluxCartConstants.CART_ADD:
add(action.sku, action.update);
break;
// Respond to CART_VISIBLE action
case FluxCartConstants.CART_VISIBLE:
setCartVisible(action.cartVisible);
break;
// Respond to CART_REMOVE action
case FluxCartConstants.CART_REMOVE:
removeItem(action.sku);
break;
default:
return true;
}
// If action was responded to, emit change event
CartStore.emitChange();
return true;
});
module.exports = CartStore;
Above, we laid out our store similarly to the way we laid out our ProductStore
. We use the _products
object to store the products that are currently in our cart, and the _cartVisibility
boolean to define the current visibility status of our cart.
We added some more complex public methods that allow our Controller View to retrieve application state:
getCartItems
- Returns the items in our cartgetCartCount
- Returns a count of how many items are in our cartgetCartTotal
- Returns the total price of all of our cart itemsNow that we have our Stores built, it’s time to get our hands dirty and build out our Views.
Our Controller View is our top-level component that listens for changes on our stores and then updates our application’s state by calling our Store’s public methods. This state is then passed down to child components via props.
The Controller View is responsible for:
var React = require('react');
var CartStore = require('../stores/CartStore');
var ProductStore = require('../stores/ProductStore');
var FluxProduct = require('./FluxProduct.react');
var FluxCart = require('./FluxCart.react');
// Method to retrieve state from Stores
function getCartState() {
return {
product: ProductStore.getProduct(),
selectedProduct: ProductStore.getSelected(),
cartItems: CartStore.getCartItems(),
cartCount: CartStore.getCartCount(),
cartTotal: CartStore.getCartTotal(),
cartVisible: CartStore.getCartVisible()
};
}
// Define main Controller View
var FluxCartApp = React.createClass({
// Get initial state from stores
getInitialState: function() {
return getCartState();
},
// Add change listeners to stores
componentDidMount: function() {
ProductStore.addChangeListener(this._onChange);
CartStore.addChangeListener(this._onChange);
},
// Remove change listers from stores
componentWillUnmount: function() {
ProductStore.removeChangeListener(this._onChange);
CartStore.removeChangeListener(this._onChange);
},
// Render our child components, passing state via props
render: function() {
return (
<div className="flux-cart-app">
<FluxCart products={this.state.cartItems} count={this.state.cartCount} total={this.state.cartTotal} visible={this.state.cartVisible} />
<FluxProduct product={this.state.product} cartitems={this.state.cartItems} selected={this.state.selectedProduct} />
</div>
);
},
// Method to setState based upon Store changes
_onChange: function() {
this.setState(getCartState());
}
});
module.exports = FluxCartApp;
We start by creating a public method called getCartState
. We use this method to call public methods on Stores to retrieve their current state and set our applications state with the results. We call this method first during the getInitialState
method, and also when a Store’s change event is received.
In order to receive these change events, we add listeners on our Stores during the mounting process, so that we know when they change. We remove these events when/if our component is unmounted.
In our render
method, we compose our component using the FluxCart
and FluxProduct
components. Here, we pass our state props down to them using component properties, or props.
It’s time to get down to the meat and potatoes of this app, and that is our Product view. We want to take the props that got passed from our Controller View and make a rich, interactive product display.
Let’s get this party started.
var React = require('react');
var FluxCartActions = require('../actions/FluxCartActions');
// Flux product view
var FluxProduct = React.createClass({
// Add item to cart via Actions
addToCart: function(event){
var sku = this.props.selected.sku;
var update = {
name: this.props.product.name,
type: this.props.selected.type,
price: this.props.selected.price
}
FluxCartActions.addToCart(sku, update);
FluxCartActions.updateCartVisible(true);
},
// Select product variation via Actions
selectVariant: function(event){
FluxCartActions.selectProduct(event.target.value);
},
// Render product View
render: function() {
var ats = (this.props.selected.sku in this.props.cartitems) ?
this.props.selected.inventory - this.props.cartitems[this.props.selected.sku].quantity :
this.props.selected.inventory;
return (
<div className="flux-product">
<img src={'img/' + this.props.product.image}/>
<div className="flux-product-detail">
<h1 className="name">{this.props.product.name}</h1>
<p className="description">{this.props.product.description}</p>
<p className="price">Price: ${this.props.selected.price}</p>
<select onChange={this.selectVariant}>
{this.props.product.variants.map(function(variant, index){
return (
<option key={index} value={index}>{variant.type}</option>
)
})}
</select>
<button type="button" onClick={this.addToCart} disabled={ats > 0 ? '' : 'disabled'}>
{ats > 0 ? 'Add To Cart' : 'Sold Out'}
</button>
</div>
</div>
);
},
});
module.exports = FluxProduct;
Prior to our render
method, we define Action methods that we bind to elements in our component. By importing our Actions, we can then call them from these methods, and kick off the update process:
selectProduct
- Sets which product option is currently selectedaddToCart
- Adds the currently selected product to the cart and opens the cartInside of our render method, we calculate how many units of the selected product are available to sell (ats) by checking how many are in the cart vs the selected products inventory. We use this to toggle the “Add To Cart” button’s state.
Now, we need a cart. So let’s put one together. In our app, when a product is added to the cart, a single line item represents the selected option. The quantity of that option can be increased, but it won’t create new line items. Instead, the quantity being purchased is displayed and the line item’s price adjusts accordingly.
Let’s make this happen:
var React = require('react');
var FluxCartActions = require('../actions/FluxCartActions');
// Flux cart view
var FluxCart = React.createClass({
// Hide cart via Actions
closeCart: function(){
FluxCartActions.updateCartVisible(false);
},
// Show cart via Actions
openCart: function(){
FluxCartActions.updateCartVisible(true);
},
// Remove item from Cart via Actions
removeFromCart: function(sku){
FluxCartActions.removeFromCart(sku);
FluxCartActions.updateCartVisible(false);
},
// Render cart view
render: function() {
var self = this, products = this.props.products;
return (
<div className={"flux-cart " + (this.props.visible ? 'active' : '')}>
<div className="mini-cart">
<button type="button" className="close-cart" onClick={this.closeCart}>×</button>
<ul>
{Object.keys(products).map(function(product){
return (
<li key={product}>
<h1 className="name">{products[product].name}</h1>
<p className="type">{products[product].type} x {products[product].quantity}</p>
<p className="price">${(products[product].price * products[product].quantity).toFixed(2)}</p>
<button type="button" className="remove-item" onClick={self.removeFromCart.bind(self, product)}>Remove</button>
</li>
)
})}
</ul>
<span className="total">Total: ${this.props.total}</span>
</div>
<button type="button" className="view-cart" onClick={this.openCart} disabled={Object.keys(this.props.products).length > 0 ? "" : "disabled"}>View Cart ({this.props.count})</button>
</div>
);
},
});
module.exports = FluxCart;
And now we have a cart! Our cart component has three event methods:
closeCart
- Close the cartopenCart
- Opens the cartremoveFromCart
- Removes item from the cart and closes the cartWhen rendering our cart, we use the map
method to render out our line items. Notice on the <li>
tag, we added the key
attribute. This is a special attribute used when adding dynamic children to a component. It is used internally in React to uniquely identify said children so that they retain the proper order and state during reconciliation. If you remove this and open your console, you can see that React will throw a warning telling you that key
hasn’t been set, and you will likely experience rendering anomalies.
For our cart’s hide and show functionality, all that we are doing is adding and removing an active
class, and letting CSS do the rest.
If you have been following along with the source or building from scratch, hit index.html
and you can see our app in action. Otherwise, check out the link to the demo below. Add items to the cart until inventory depletes to see our buttons’ states change and the totals update in the cart.
Don’t stop there though, take the demo source and try to add some new features to our cart, like a product grid display using react-router or adding more than 1 selectable option per product. The sky’s the limit folks.
This marks the end of the Learning React series, and I hope everybody has had as much fun learning it as I did writing it. I’m of the firm belief that 2015 will be the year of React, so take what you have learned here, get out there and build some cool stuff.
Thanks for learning with the DigitalOcean Community. Check out our offerings for compute, storage, networking, and managed databases.
This textbox defaults to using Markdown to format your answer.
You can type !ref in this text area to quickly search our full set of tutorials, documentation & marketplace offerings and insert the link!