Build a realtime shopping cart with Java and React

Introduction

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:

  • Choose the quantity to add a product to the shopping cart (if the product is already in the shopping cart, the quantity will be updated)
  • Remove a product from the shopping cart
  • Calculate the total when a product is added/updated/removed
  • Empty the shopping cart

This is how the final application will look:

realtime-shopping-cart-java-react-demo

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.

Setting up Pusher

Create a free account with Pusher.

When you first log in, you'll be asked to enter some configuration options:

realtime-shopping-cart-java-react-create-pusher-app

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.

realtime-shopping-cart-java-react-pusher-libraries

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.

Setting up the application

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:

  • Web
  • Thymeleaf

Enter a Group ID, an Artifact ID and generate the project:

realtime-shopping-cart-java-react-Sprint-Initializr

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>

The Java Back-end

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 + Pusher

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):

realtime-shopping-cart-java-react-components

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:

realtime-shopping-cart-java-react-final-app

Conclusion

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.

Further reading