Programming a shopping cart may not be as easy as you think. One of the greatest challenges is to synchronize the content of a user's shopping cart between devices or even browser tabs.
For example, a friend sends you, via a messaging app on your phone, the link to a great deal on the latest video game that you've been dying to get. You add the game to your shopping cart, but for some reason, you prefer to go through the checkout process on your desktop computer. You log into your account and that's when you realize that there's nothing in your shopping cart. You add the video game to the shopping cart again on your phone, but nothing appears on the other side. Have you experienced this before? Are you a developer that doesn't want your users to go through the same annoyance? If so, keep reading.
In this tutorial, we're going to build a simple realtime shopping cart, using Pusher to solve the synchronization issue we mentioned earlier. When an action like a quantity update or an item is removed from the Cart, a Pusher event will be sent so all the listening devices, windows or tabs can be synchronized accordingly.
The stack will be the following:
To keep things simple, we won't use a database. We'll keep a list of four products in memory, the app will only support one user, and the cart items will be stored in a web session.
The server will provide a REST API so the front-end can work just as a presentation layer with AJAX calls. For complex applications, the recommended way to do this is by using something like Redux. In fact, in the Redux documentation you can find a shopping cart example. However, once again, to keep things simple, we are going to issue all of our AJAX requests from the parent component using fetch.
In summary, our shopping cart will have the following functionality:
This is how the final application will look:
This tutorial assumes prior knowledge of Java 8, Spring Boot/MVC and React. We will integrate Pusher into a Spring MVC REST API, create React components and hook them up with Pusher.
You can find the entire code of the application on Github.
Create a free account with Pusher.
When you first log in, you'll be asked to enter some configuration options:
Enter a name, choose React as your front-end tech, and Java as your back-end tech. This will give you some sample code to get you started.
This won't lock you into a specific set of technologies, you can always change them. With Pusher, you can use any combination of libraries.
Then go to the App Keys tab to copy your App ID, Key, and Secret credentials - we'll need them later.
One of the easiest ways to create a Spring Boot app is to use the project generator at https://start.spring.io/.
Go to that page and choose to generate a Maven project with the following dependencies:
Enter a Group ID, an Artifact ID and generate the project:
Unzip the content of the downloaded file. At this point, you can import the project to an IDE if you want.
Now open the pom.xml
file and add the Pusher library to the dependencies
section:
1<dependency> 2 <groupId>com.pusher</groupId> 3 <artifactId>pusher-http-java</artifactId> 4 <version>1.0.0</version> 5</dependency>
Let's start with the com/pusher/web/IndexController class. It defines the root route (/
) that shows an index
template, passing the Pusher App Key and the channel name where the events will be published:
1@Controller 2@SessionAttributes(GeneralConstants.ID_SESSION_SHOPPING_CART) 3public class IndexController { 4 5 @RequestMapping(method=RequestMethod.GET, value="/") 6 public ModelAndView index(Model model) { 7 ModelAndView modelAndView = new ModelAndView(); 8 9 modelAndView.setViewName("index"); 10 modelAndView.addObject("pusher_app_key", PusherConstants.PUSHER_APP_KEY); 11 modelAndView.addObject("pusher_channel", PusherConstants.CHANNEL_NAME); 12 13 if(!model.containsAttribute(GeneralConstants.ID_SESSION_SHOPPING_CART)) { 14 model.addAttribute(GeneralConstants.ID_SESSION_SHOPPING_CART, new ArrayList<Product>()); 15 } 16 17 return modelAndView; 18 } 19}
The @SessionAttributes
annotation defines the identifier of an attribute that will be added to the session automatically when an object with the same identifier is added to the model object. This way, if a list of products (representing the shopping cart) is not in the session already, an empty one is created.
As this application supports only one user, the name of the channel is fixed. However, in a real application, the shopping cart of each user will use a different Pusher channel, so the name would have to be unique. But there's no problem, Pusher Channels offers unlimited channels on all of its plans.
Then, we have the com/pusher/web/CartController class, where the REST API for our shopping cart is defined. First, we define the configure()
method that is called after dependency injection is done to initialize the Pusher object and the list of products:
1@RestController 2@SessionAttributes(GeneralConstants.ID_SESSION_SHOPPING_CART) 3public class CartController { 4 5 private List<Product> products = new ArrayList<Product>(); 6 7 private Pusher pusher; 8 9 @PostConstruct 10 public void configure() { 11 pusher = new Pusher( 12 PusherConstants.PUSHER_APP_ID, 13 PusherConstants.PUSHER_APP_KEY, 14 PusherConstants.PUSHER_APP_SECRET 15 ); 16 17 Product product = new Product(); 18 product.setId(1L); 19 product.setName("Office Chair"); 20 product.setPrice(new BigDecimal("55.99")); 21 products.add(product); 22 23 product = new Product(); 24 product.setId(2L); 25 product.setName("Sunglasses"); 26 product.setPrice(new BigDecimal("99.99")); 27 products.add(product); 28 29 product = new Product(); 30 product.setId(3L); 31 product.setName("Wireless Headphones"); 32 product.setPrice(new BigDecimal("349.01")); 33 products.add(product); 34 35 product = new Product(); 36 product.setId(4L); 37 product.setName("External Hard Drive"); 38 product.setPrice(new BigDecimal("89.99")); 39 products.add(product); 40 } 41 42 ... 43}
Next, we define the endpoints to get, in JSON format, the list of products as well as the products in the shopping cart:
1public class CartController { 2 3 ... 4 5 @RequestMapping(value = "/products", 6 method = RequestMethod.GET, 7 produces = "application/json") 8 public List<Product> getProducts() { 9 return products; 10 } 11 12 @RequestMapping(value = "/cart/items", 13 method = RequestMethod.GET, 14 produces = "application/json") 15 public List<Product> getCartItems(@SessionAttribute(GeneralConstants.ID_SESSION_SHOPPING_CART) List<Product> shoppingCart) { 16 return shoppingCart; 17 } 18 19 ... 20}
A method to search for a product by its identifier in a list of products would be handy, so let's define one using the Java 8 Stream API to do it in a functional style:
1private Optional<Product> getProductById(Stream<Product> stream, Long id) { 2 return stream 3 .filter(product -> product.getId().equals(id)) 4 .findFirst(); 5}
This way, to add a product, we look for the product passed in the catalog of products (to see if it's a valid one) and then, if the product is in the shopping cart already, we update its quantity, otherwise, we added directly to the shopping cart, triggering an itemUpdated
or itemAdded
accordingly:
1public class CartController { 2 3 ... 4 5 @RequestMapping(value = "/cart/item", 6 method = RequestMethod.POST, 7 consumes = "application/json") 8 public String addItem(@RequestBody ItemRequest request, @SessionAttribute(GeneralConstants.ID_SESSION_SHOPPING_CART) List<Product> shoppingCart) { 9 Product newProduct = new Product(); 10 Optional<Product> optional = getProductById(products.stream(), request.getId()); 11 12 if (optional.isPresent()) { 13 Product product = optional.get(); 14 15 newProduct.setId(product.getId()); 16 newProduct.setName(product.getName()); 17 newProduct.setPrice(product.getPrice()); 18 newProduct.setQuantity(request.getQuantity()); 19 20 Optional<Product> productInCart = getProductById(shoppingCart.stream(), product.getId()); 21 String event; 22 23 if(productInCart.isPresent()) { 24 productInCart.get().setQuantity(request.getQuantity()); 25 event = "itemUpdated"; 26 } else { 27 shoppingCart.add(newProduct); 28 event = "itemAdded"; 29 } 30 31 pusher.trigger(PusherConstants.CHANNEL_NAME, event, newProduct); 32 } 33 34 return "OK"; 35 } 36 37 ... 38}
Deleting a product from the shopping cart is similar. If the product is valid (if it exists in the catalog), we look for it on the shopping cart to remove it and trigger an itemRemoved
event on Pusher:
1public class CartController { 2 3 ... 4 5 @RequestMapping(value = "/cart/item", 6 method = RequestMethod.DELETE, 7 consumes = "application/json") 8 public String deleteItem(@RequestBody ItemRequest request, @SessionAttribute(GeneralConstants.ID_SESSION_SHOPPING_CART) List<Product> shoppingCart) { 9 Optional<Product> optional = getProductById(products.stream(), request.getId()); 10 11 if (optional.isPresent()) { 12 Product product = optional.get(); 13 14 Optional<Product> productInCart = getProductById(shoppingCart.stream(), product.getId()); 15 16 if(productInCart.isPresent()) { 17 shoppingCart.remove(productInCart.get()); 18 pusher.trigger(PusherConstants.CHANNEL_NAME, "itemRemoved", product); 19 } 20 } 21 22 return "OK"; 23 } 24 25 ... 26}
Finally, to empty the cart, we just replace the cart in the session with an empty list and trigger the cartEmptied
Pusher event:
1public class CartController { 2 3 ... 4 5 @RequestMapping(value = "/cart", 6 method = RequestMethod.DELETE) 7 public String emptyCart(Model model) { 8 model.addAttribute(GeneralConstants.ID_SESSION_SHOPPING_CART, new ArrayList<Product>()); 9 pusher.trigger(PusherConstants.CHANNEL_NAME, "cartEmptied", ""); 10 11 return "OK"; 12 } 13 14 ... 15}
React thinks of the UI as a set of components, where you simply update a component's state, and then React renders a new UI based on this new state updating the DOM for you in the most efficient way.
The app's UI will be organized into five components, a header (Header
), the cart (Cart
), a component for each cart item (CartItem
), the product list (ProductList
), and a component for each product (Product
):
The template for the index page just contains references to the CSS files, a page header, a div
element where the UI will be rendered, the Pusher app key and channel name (passed from the server), and references to all the Javascript files the application uses:
1<!DOCTYPE html> 2<html xmlns:th="http://www.thymeleaf.org"> 3<head> 4 <meta charset="utf-8" /> 5 <meta name="viewport" content="width=device-width, initial-scale=1" /> 6 <title>Real-time shopping cart with Pusher, Java, and React</title> 7 8 <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css" /> 9 <link rel="stylesheet" href="http://netdna.bootstrapcdn.com/font-awesome/4.0.3/css/font-awesome.min.css" /> 10 <link rel="stylesheet" href="/css/style.css" /> 11</head> 12<body class="blue-gradient-background"> 13 14 <nav class="navbar navbar-inverse"> 15 <div class="container"> 16 <div class="navbar-header"> 17 <a class="navbar-brand" href="https://pusher.com"> 18 <img class="logo" src="/images/pusher-logo.png" width="111" height="37"/> 19 </a> 20 </div> 21 22 <p class="navbar-text navbar-right"><a class="navbar-link" href="http://pusher.com/signup">Create a Free Account</a></p> 23 </div> 24 </nav> 25 26 <div id="app"></div> 27 28 <!-- React --> 29 <script src="https://unpkg.com/react@15.4.1/dist/react-with-addons.js"></script> 30 <script src="https://unpkg.com/react-dom@15.4.1/dist/react-dom.js"></script> 31 <script src="https://unpkg.com/babel-standalone@6.19.0/babel.min.js"></script> 32 33 <!-- Libs --> 34 <script src="https://cdnjs.cloudflare.com/ajax/libs/fetch/2.0.1/fetch.js"></script> 35 <script src="https://js.pusher.com/4.0/pusher.min.js"></script> 36 37 <!-- Pusher Config --> 38 <script th:inline="javascript"> 39 var PUSHER_APP_KEY = /*[[${pusher_app_key}]]*/ 'NA'; 40 var PUSHER_CHANNEL_NAME = /*[[${pusher_channel}]]*/ 'NA'; 41 </script> 42 43 <!-- App/Components --> 44 <script type="text/babel" src="/js/components/header.js"></script> 45 <script type="text/babel" src="/js/components/cartItem.js"></script> 46 <script type="text/babel" src="/js/components/cart.js"></script> 47 <script type="text/babel" src="/js/components/product.js"></script> 48 <script type="text/babel" src="/js/components/productList.js"></script> 49 <script type="text/babel" src="/js/app.js"></script> 50 51</body> 52</html>
The application will be rendered in the div
element with the ID app
. The file static/js/app.js is the starting point for our React app:
1var App = React.createClass({ 2 ... 3}); 4 5ReactDOM.render(<App />, document.getElementById("app"));
Inside the App
class, first, we define our state as arrays of cart items and products:
1var App = React.createClass({ 2 3 getInitialState: function() { 4 return { items: [], products: [] }; 5 }, 6 7 ... 8 9}); 10 11...
Then, we use the componentWillMount
method, which is invoked once immediately before the initial rendering occurs, to set up Pusher and a variable to keep the cart total:
1var App = React.createClass({ 2 ... 3 4 componentWillMount: function() { 5 this.pusher = new Pusher(PUSHER_APP_KEY, { 6 encrypted: true, 7 }); 8 this.channel = this.pusher.subscribe(PUSHER_CHANNEL_NAME); 9 this.total = 0; 10 }, 11 12 ... 13}); 14 15...
We subscribe to the channel's events in the componentDidMount
method and get the catalog of products and any existing content of the shopping cart using fetch:
1var App = React.createClass({ 2 3 ... 4 5 componentDidMount() { 6 this.channel.bind('itemAdded', this.itemAdded); 7 this.channel.bind('itemUpdated', this.itemUpdated); 8 this.channel.bind('itemRemoved', this.itemRemoved); 9 this.channel.bind('cartEmptied', this.cartEmptied); 10 11 fetch('/products').then(function(response) { 12 return response.json(); 13 }).then(this.getProductsSuccess); 14 15 fetch('/cart/items', { 16 credentials: 'same-origin', 17 }).then(function(response) { 18 return response.json(); 19 }).then(this.getCartItemsSuccess); 20 } 21 22 ... 23}); 24 25...
The callbacks used when the products and cart items are fetched from the server just update the state of the component and calculate the cart total using the countTotal
function:
1var App = React.createClass({ 2 3 ... 4 5 getProductsSuccess: function(response) { 6 this.setState({ 7 products: response 8 }); 9 }, 10 11 getCartItemsSuccess: function(response) { 12 this.countTotal(response); 13 this.setState({ 14 items: response 15 }); 16 }, 17 18 countTotal: function(newArray) { 19 var temp = 0; 20 21 newArray.forEach(function(item, index) { 22 temp += (item.price * item.quantity); 23 }); 24 25 this.total = temp; 26 }, 27 28 ... 29}); 30 31...
In the componentWillUnmount
method, we unsubscribe from the Pusher events and in case the AJAX requests have not been completed at that point, we assign an empty function to the callbacks to do nothing when the component is unmounted:
1var App = React.createClass({ 2 3 ... 4 5 componentWillUnmount: function() { 6 this.channel.unbind(); 7 8 this.pusher.unsubscribe(this.channel); 9 10 this.getProductsSuccess = function() {}; 11 this.getCartItemsSuccess = function() {}; 12 }, 13 14 ... 15}); 16 17...
When an itemAdded
event is received, the total is updated and the new item is added to a new array, which is used to update the state so React can re-render the components:
1var App = React.createClass({ 2 3 ... 4 5 itemAdded: function(item) { 6 var newArray = this.state.items.slice(0); 7 newArray.push(item); 8 9 this.countTotal(newArray); 10 11 this.setState({ 12 items: newArray, 13 }); 14 }, 15 16 ... 17}); 18 19...
Something similar happens with the itemUpdated
and itemRemoved
events, the difference is that the index of the item being referenced is looked up using the some function to update/remove it:
1var App = React.createClass({ 2 3 ... 4 5 itemUpdated: function(item) { 6 var newArray = this.state.items.slice(0); 7 var indexToUpdate; 8 9 this.state.items.some(function(it, index) { 10 if(it.id === item.id) { 11 indexToUpdate = index; 12 return true; 13 } 14 }); 15 16 newArray[indexToUpdate].quantity = item.quantity; 17 18 this.countTotal(newArray); 19 20 this.setState({ 21 items: newArray, 22 }); 23 }, 24 25 itemRemoved: function(item) { 26 var newArray = this.state.items.slice(0); 27 var indexToRemove; 28 29 this.state.items.some(function(it, index) { 30 if(it.id === item.id) { 31 indexToRemove = index; 32 return true; 33 } 34 }); 35 36 newArray.splice(indexToRemove, 1); 37 38 this.countTotal(newArray); 39 40 this.setState({ 41 items: newArray, 42 }); 43 }, 44 45 ... 46}); 47 48...
And, when the cart is emptied, we just update the state with an empty array. Notice how in all cases, we worked with a copy of the existing array, since React works best with immutable objects:
1var App = React.createClass({ 2 3 ... 4 5 cartEmptied: function() { 6 var newArray = []; 7 this.countTotal(newArray); 8 9 this.setState({ 10 items: newArray 11 }); 12 }, 13 14 ... 15 16}); 17 18...
Finally, the render
method shows the top-level components of our app:
1var App = React.createClass({ 2 3 ... 4 5 render: function() { 6 return ( 7 <div className="container"> 8 <Header /> 9 <Cart items={this.state.items} total={this.total} /> 10 <ProductList products={this.state.products} /> 11 </div> 12 ); 13 } 14 15 ... 16} 17 18...
static/js/header.js is a simple component without state or properties that only renders the HTML for the page's title.
The Cart
component (public/js/cart.js) takes the array of items to create an array of CartItem
components and define an emptyCart
function to call the API endpoint for that functionality:
1var Cart = React.createClass({ 2 emptyCart: function() { 3 fetch('/cart', { 4 credentials: 'same-origin', 5 method: 'DELETE' 6 }); 7 }, 8 9 render: function() { 10 var itemsMapped = this.props.items.map(function (item, index) { 11 return <CartItem item={item} key={index} /> 12 }); 13 14 var empty = <div className="alert alert-info">Cart is empty</div>; 15 16 return ( 17 <div className="row extra-bottom-margin"> 18 <div className="col-xs-8 col-xs-offset-2"> 19 <div className="panel panel-info"> 20 <div className="panel-heading"> 21 <div className="panel-title"> 22 <div className="row"> 23 <div className="col-xs-12"> 24 <h5><span className="glyphicon glyphicon-shopping-cart"></span> Shopping Cart</h5> 25 </div> 26 </div> 27 </div> 28 </div> 29 <div className="panel-body"> 30 <div className="row"> 31 <div className="col-xs-6"> 32 <h6><strong>Product</strong></h6> 33 </div> 34 <div className="col-xs-6"> 35 <div className="col-xs-4 text-center"> 36 <h6><strong>Price</strong></h6> 37 </div> 38 <div className="col-xs-4 text-center"> 39 <h6><strong>Quantity</strong></h6> 40 </div> 41 <div className="col-xs-4 text-center"></div> 42 </div> 43 </div> 44 {itemsMapped.length > 0 ? itemsMapped : empty} 45 </div> 46 <div className="panel-footer">; 47 <div className="row text-center"> 48 <div className="col-xs-9"> 49 <h4 className="text-right">Total <strong>${this.props.total}</strong></h4> 50 </div> 51 <div className="col-xs-3"> 52 <button type="button" className="btn btn-info btn-sm btn-block" onClick={this.emptyCart} disabled={itemsMapped.length == 0}> 53 Empty cart 54 </button> 55 </div> 56 </div> 57 </div> 58 </div> 59 </div> 60 </div> 61 ); 62 } 63});
The CartItem
component (static/js/cartItem.js) defines functions to remove the item from the shopping cart (passing its identifier) and render it:
1var CartItem = React.createClass({ 2 deleteItem: function() { 3 fetch('/cart/item', { 4 credentials: 'same-origin', 5 method: 'DELETE', 6 headers: { 7 'Content-Type': 'application/json' 8 }, 9 body: JSON.stringify({ 10 id: this.props.item.id, 11 }) 12 }); 13 }, 14 15 render: function() { 16 var name = this.props.item.name; 17 var id = this.props.item.id; 18 var price = this.props.item.price; 19 var quantity = this.props.item.quantity; 20 21 return ( 22 <div className="row cart-item"> 23 <div className="col-xs-6"> 24 <h6 className="product-name"><strong>{name}</strong></h6> 25 </div> 26 <div className="col-xs-6"> 27 <div className="col-xs-4 text-center"> 28 <h6>{price}</h6> 29 </div> 30 <div className="col-xs-4 text-center"> 31 <h6>{quantity}</h6> 32 </div> 33 <div className="col-xs-4 text-center"> 34 <button type="button" className="btn btn-link btn-xs" onClick={this.deleteItem}> 35 <i className="fa fa-trash-o fa-lg"></i> 36 </button> 37 </div> 38 </div> 39 </div> 40 ); 41 } 42});
On the other hand, the ProductList
component (static/js/productList.js) takes the array of products to create an array of Product
components:
1var ProductList = React.createClass({ 2 render: function() { 3 4 var productsMapped = this.props.products.map(function (product, index) { 5 return <Product product={product} key={index} /> 6 }); 7 8 return ( <div className="row extra-bottom-margin"> {productsMapped} </div> ); 9 } 10});
While the Product
component defines quantity
as its state, a function to call the API endpoint to add an item to the shopping cart and render a product:
1var Product = React.createClass({ 2 getInitialState: function() { 3 return { 4 quantity: 1 5 }; 6 }, 7 8 updateQuantity: function(evt) { 9 this.setState({ 10 quantity: evt.target.value 11 }); 12 }, 13 14 addToCart: function() { 15 fetch('/cart/item', { 16 credentials: 'same-origin', 17 method: 'POST', 18 headers: { 19 'Content-Type': 'application/json' 20 }, 21 body: JSON.stringify({ 22 id: this.props.product.id, 23 quantity: this.state.quantity, 24 }) 25 }); 26 }, 27 28 render: function() { 29 var name = this.props.product.name; 30 var id = this.props.product.id; 31 var price = this.props.product.price; 32 33 return ( 34 <div className="col-sm-3"> 35 <div className="col-item"> 36 <div className="photo"> 37 <img src="http://placehold.it/200x150" className="img-responsive" alt="a" /> 38 </div> 39 <div className="info"> 40 <div className="row"> 41 <div className="price col-md-12"> 42 <h5>{name}</h5> 43 <h5 className="price-text-color">${price}</h5> 44 </div> 45 </div> 46 <div className="separator clear-left"> 47 <p className="section-qty"> 48 <input className="form-control input-sm" type="text" value={this.state.quantity} onChange={this.updateQuantity} /> 49 </p> 50 <p className="section-add"> 51 <button type="button" className="btn btn-link btn-xs" onClick={this.addToCart}> 52 <i className="fa fa-shopping-cart"></i><span className="hidden-sm">Add to cart</span> 53 </button> 54 </p> 55 </div> 56 <div className="clearfix"></div> 57 </div> 58 </div> 59 </div> 60 ); 61 } 62});
Finally, you can run the application either by executing the com.pusher.ShoppingCartApplication
class on your IDE, or on the command line with:
$ mvn spring-boot:run
Additionally, on the command line, you can create a JAR file and execute it:
1$ mvn package -DskipTests 2$ java -jar target/shopping-cart-0.0.1-SNAPSHOT.jar
Now, when you open http://localhost:8080/
in two browser windows at the same time, the actions made in one window should be reflected on the other one:
In this tutorial, we saw how to integrate Pusher into a Java back-end and a React front-end. As you can see, it is trivial and easy to add Pusher to your app and start adding new features. You can start on the forever free plan that includes 100 max connections, unlimited channels, 200k daily messages, and SSL protection. Signup now!
Remember that if you get stuck, you can find the final version of this code on Github or contact us with your questions.