[flaviocopes] The React Course - React eCommerce Site

Use React to build an e-commerce website, complete with backend and frontend

I recommend to keep those resources of mine
at hand while following the course:

Introduction to the e-commerce site

In this module, we’re going to build an e-commerce website.

We’ll have to manage products, and the shopping cart.

We’ll also need different URLs based on the thing that’s displayed on the screen. A products list, a single product, the cart, and we’ll use React Router library to handle that.

The app will have several different screens.

One will allow us to add new products, without a full-blown administration interface. We’re not building a production-ready app so we can help us easily test things by having a quick way to add products.

Then we’ll have a products listing

and a product details page:

From there we can add items to the shopping cart, which will show up below the page menu.

When there is at least one item in the shopping cart we can check out. We’ll be using Stripe Checkout for that.

Once an order is sent, we’ll have a confirmation page.

Let’s go!

The project structure

Let’s start up the project.

Like with the Pixel Art project, and the Bill Tracker app, we’re going to use create-react-app .

As usual, you are free to use it locally or use the web-based CodeSandbox environment.

While in the previous projects I used CodeSandbox as a reference environment, this time I use create-react-app locally. The full source code at the end of the project is available on GitHub: https://github.com/flaviocopes/react-ecommerce

This time, the project needs one major React library: React Router . It radically influences the codebase, and I suggest you to read my React Router introduction on the React Handbook or on my blog.

If you use CodeSandbox click Add Dependency in the project sidebar and search the react-router package.

Locally, install it using

npm install react-router-dom

Let’s start!

Setting up the routes

Now that we have a common base whether you use the create-react-app locally or CodeSandbox, we can start building our app.

Given the premise of the introduction, we’ll start with 3 components: AddProduct , ProductsList and SingleProduct , which will provide

  • the add product form
  • the products list
  • the single product view

Those components will all be added to the navigation on top of the page, along with “Home” and “About”.

Let’s start by adding them, with a minimal boilerplate to just see them working as part of the router:

Create src/components folder and add

src/components/AddProduct.js

import React from 'react'

export default () => {
  return <div>AddProduct</div>
}

src/components/SingleProduct.js

import React from 'react'

export default () => {
  return <div>SingleProduct</div>
}

src/components/ProductsList.js

import React from 'react'

export default () => {
  return <div>ProductsList</div>
}

Next, open App.js.

First we need to import React router:

import { BrowserRouter as Router, Link, Route } from 'react-router-dom'

Then we import the components we just created:

import AddProduct from './components/AddProduct.js'
import ProductsList from './components/ProductsList.js'
import SingleProduct from './components/SingleProduct.js'

Now we can use this React Router basic structure:

const App = () => {
  return (
    <Router>
      <div>
        <aside>
          <Link to={`/`}>Products</Link>
          <Link to={`/add-product`}>Add product</Link>
        </aside>

        <main>
          <Route exact path="/" component={ProductsList} />
          <Route
            path="/add-product"
            component={AddProduct}
          />
          <Route path="/product/:slug" component={SingleProduct} />
        </main>
      </div>
    </Router>
  )
}

Notice how we add a specific path to each component, and the Product component accepts a dynamic path /product/:slug . There will be multiple pages on the site that match the Product component, one for each different product.

Now replace the src/App.css content with this:

input,
textarea {
  border: 1px solid #ccc;
  padding: 20px;
  vertical-align: middle;
}

label {
  vertical-align: middle;
  padding-right: 10px;
}

div {
  display: block;
  padding: 50px;
  font-size: 1.5rem;
}

.button {
  background-color: #2c3e50;
  color: white;
  font-size: 1.5rem;
  width: 50%;
}

#app {
  font-family: 'Avenir', Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
}
aside {
  padding: 30px;
}

aside a {
  font-weight: bold;
  color: #2c3e50;
  padding: 20px;
}

aside a.router-link-exact-active {
  color: #42b983;
}

You don’t need to understand it if you are not a CSS expert, but it should just give the app a better look.

The final result should be this:

And you should be able to see the AddProduct screen when you click the link.

Adding products

Let’s create the form that adds a new product.

The AddProduct component is the piece of the app responsible for showing the form and intercepting the form submit event.

We now have this:

import React from 'react'

export default () => {
  return <div>AddProduct</div>
}

We want to have a form where we can enter the product name, the product description, the price and a link to a picture (not uploads now, as we don’t want to get in o the backend part).

First, we define the markup of the form:

import React from 'react'

export default props => {
  return (
    <div>
      <form>
        <h1>Add Product</h1>
        <div>
          <label>Name:</label>
          <input required />
        </div>
        <div>
          <label>Price in $:</label>
          <input required />
        </div>
        <div>
          <label>Description:</label>{' '}
          <textarea required />
        </div>
        <div>
          <label>Image URL:</label>
          <input required />
        </div>
        <input type="submit" value="Add" className="button" />
      </form>
    </div>
  )
}

We need to handle state, and we do this using Hooks. Import useState :

import React, { useState } from 'react'

and then define 4 state values:

const [name, setName] = useState()
const [price, setPrice] = useState()
const [description, setDescription] = useState()
const [image, setImage] = useState()

When an input element changes, we need to alter those state values. Check out how to handle forms in React in https://flaviocopes.com/react-forms/.

import React, { useState } from 'react'

export default props => {
  const [name, setName] = useState()
  const [price, setPrice] = useState()
  const [description, setDescription] = useState()
  const [image, setImage] = useState()

  const handleChangeName = e => {
    setName(e.target.value)
  }

  const handleChangePrice = e => {
    setPrice(e.target.value)
  }

  const handleChangeDescription = e => {
    setDescription(e.target.value)
  }

  const handleChangeImage = e => {
    setImage(e.target.value)
  }

  return (
    <div>
      <form>
        <h1>Add Product</h1>
        <div>
          <label>Name:</label>
          <input required onChange={handleChangeName} />
        </div>
        <div>
          <label>Price in $:</label>
          <input required onChange={handleChangePrice} />
        </div>
        <div>
          <label>Description:</label>{' '}
          <textarea required onChange={handleChangeDescription} />
        </div>
        <div>
          <label>Image URL:</label>
          <input required onChange={handleChangeImage} />
        </div>
        <input type="submit" value="Add" className="button" />
      </form>
    </div>
  )
}

Then when the form is submitted, we need to handle that as well. We pass an event handler to the onSubmit event on the form element. In this, we call the addProduct() prop, passing to the parent component (App) the values that we stored in the local state:

import React, { useState } from 'react'

export default props => {
  //...

  const addProduct = e => {
    e.preventDefault()
    props.addProduct({ name, price, description, image })
  }

  //...

  return (
    <div>
      <form onSubmit={addProduct}>
        //...
      </form>
    </div>
  )
}

In App.js we add the addProduct prop. Notice how we are using a different syntax here with React router, using the render prop, which I explain in https://flaviocopes.com/react-pass-props-router/:

<Route
  path="/add-product"
  render={() => <AddProduct addProduct={addProduct} />}
/>

which calls the addProduct function. In this function we update the App products array, which we manage using Hooks as well:

import React, { useState } from 'react'
//...

const App = () => {
  const [products, setProducts] = useState([])

  const addProduct = product => {
    setProducts([...products, product])
  }

  return (
    <Router>
      <div id="app">
        //...
        <main>
          //...
          <Route
            path="/add-product"
            render={() => <AddProduct addProduct={addProduct} />}
          />
          //...
        </main>
      </div>
    </Router>
  )
}

export default App

Note that we don’t persist the products data anywhere, so every time you refresh the page, the data is lost. We’ll persist this data later.

We must do something else now, however. We need to calculate a product slug , which will be part of the product URL.

Every product has a URL like /product/product-name . We calculate this slug part when the product is added, based on its name.

Here’s the function that computes the slug:

const slugify = str => {
  str = str || ''
  const a =
    'àáäâèéëêìíïîòóöôùúüûñçßÿœæŕśńṕẃǵǹḿǘẍźḧ·/_,:;άαβγδεέζήηθιίϊΐκλμνξοόπρσςτυϋύΰφχψωώ'
  const b =
    'aaaaeeeeiiiioooouuuuncsyoarsnpwgnmuxzh------aavgdeeziitiiiiklmnxooprsstyyyyfhpoo'
  const p = new RegExp(a.split('').join('|'), 'g')

  return str
    .toString()
    .trim()
    .toLowerCase()
    .replace(/ου/g, 'ou')
    .replace(/ευ/g, 'eu')
    .replace(/θ/g, 'th')
    .replace(/ψ/g, 'ps')
    .replace(/\//g, '-')
    .replace(/\s+/g, '-') // Replace spaces with -
    .replace(p, c => b.charAt(a.indexOf(c))) // Replace special chars
    .replace(/&/g, '-and-') // Replace & with 'and'
    .replace(/[^\w\-]+/g, '') // Remove all non-word chars
    .replace(/\-\-+/g, '-') // Replace multiple - with single -
    .replace(/^-+/, '') // Trim - from start of text
    .replace(/-+$/, '') // Trim - from end of text
}

You don’t need to understand it. I picked it from StackOverflow and it does its job in my tests. It accepts a string like A mug and returns a-mug , but you don’t have to believe me: copy/paste it in the developer tools, and test some strings.

Paste it in the AddProduct component, and use it in the addProduct function:

props.addProduct({ name, price, description, image, slug: slugify(name) })

Cool! The form should be working fine now.

There’s just one thing missing. When we add a product, it would be nice to go back to the list of products, right? We must do two things to make this work.

First, from the App component we must pass the history prop to AddProduct. This object is made available by React Router as a parameter to the render prop:

<Route
  path="/add-product"
  render={({ history }) => (
    <AddProduct addProduct={addProduct} history={history} />
  )}
/>

Then we can call its push method in the addProduct function in the AddProduct component:

const addProduct = e => {
  e.preventDefault()
  props.addProduct({ name, price, description, image, slug: slugify(name) })
  props.history.push('/')
}

and once the product is added we’re moved back to the products list (with the updated URL, too)

The final code for `src/components/AddProduct.js looks like this:

import React, { useState } from 'react'

const slugify = str => {
  str = str || ''
  const a =
    'àáäâèéëêìíïîòóöôùúüûñçßÿœæŕśńṕẃǵǹḿǘẍźḧ·/_,:;άαβγδεέζήηθιίϊΐκλμνξοόπρσςτυϋύΰφχψωώ'
  const b =
    'aaaaeeeeiiiioooouuuuncsyoarsnpwgnmuxzh------aavgdeeziitiiiiklmnxooprsstyyyyfhpoo'
  const p = new RegExp(a.split('').join('|'), 'g')

  return str
    .toString()
    .trim()
    .toLowerCase()
    .replace(/ου/g, 'ou')
    .replace(/ευ/g, 'eu')
    .replace(/θ/g, 'th')
    .replace(/ψ/g, 'ps')
    .replace(/\//g, '-')
    .replace(/\s+/g, '-') // Replace spaces with -
    .replace(p, c => b.charAt(a.indexOf(c))) // Replace special chars
    .replace(/&/g, '-and-') // Replace & with 'and'
    .replace(/[^\w\-]+/g, '') // Remove all non-word chars
    .replace(/\-\-+/g, '-') // Replace multiple - with single -
    .replace(/^-+/, '') // Trim - from start of text
    .replace(/-+$/, '') // Trim - from end of text
}

export default props => {
  const [name, setName] = useState()
  const [price, setPrice] = useState()
  const [description, setDescription] = useState()
  const [image, setImage] = useState()

  const addProduct = e => {
    e.preventDefault()
    props.addProduct({ name, price, description, image, slug: slugify(name) })
    props.history.push('/')
  }

  const handleChangeName = e => {
    setName(e.target.value)
  }

  const handleChangePrice = e => {
    setPrice(e.target.value)
  }

  const handleChangeDescription = e => {
    setDescription(e.target.value)
  }

  const handleChangeImage = e => {
    setImage(e.target.value)
  }

  return (
    <div>
      <form onSubmit={addProduct}>
        <h1>Add Product</h1>
        <div>
          <label>Name:</label>
          <input required onChange={handleChangeName} />
        </div>
        <div>
          <label>Price in $:</label>
          <input required onChange={handleChangePrice} />
        </div>
        <div>
          <label>Description:</label>{' '}
          <textarea required onChange={handleChangeDescription} />
        </div>
        <div>
          <label>Image URL:</label>
          <input required onChange={handleChangeImage} />
        </div>
        <input type="submit" value="Add" className="button" />
      </form>
    </div>
  )
}

and here’s App.js:

import React, { useState } from 'react'
import './App.css'
import { BrowserRouter as Router, Link, Route } from 'react-router-dom'
import AddProduct from './components/AddProduct.js'
import ProductsList from './components/ProductsList.js'
import SingleProduct from './components/SingleProduct.js'

const App = () => {
  const [products, setProducts] = useState([])

  const addProduct = product => {
    setProducts([...products, product])
  }

  return (
    <Router>
      <div id="app">
        <aside>
          <Link to={`/`}>Products</Link>
          <Link to={`/add-product`}>Add product</Link>
        </aside>

        <main>
          <Route exact path="/" component={ProductsList} />
          <Route
            path="/add-product"
            render={({ history }) => (
              <AddProduct addProduct={addProduct} history={history} />
            )}
          />
          <Route path="/product/:slug" component={SingleProduct} />
        </main>
      </div>
    </Router>
  )
}

export default App

List the products

Now that we can successfully add products, let’s show them in the products listing page, which is managed in the ProductsList component in src/components/ProductsList.js :

import React from 'react'

export default () => {
  return <div>ProductsList</div>
}

Before we do anything else we must pass the products array from App to ProductsList, by adding a render prop like we did in the last lesson for AddProduct :

<Route
  exact
  path="/"
  render={() => <ProductsList products={products} />}
/>

Now in ProductsList, for each product we display the information we have at our disposal:

import React from 'react'

export default props => {
  return (
    <div className="products-list">
      {props.products
        ? props.products.map((value, index) => {
            return (
              <div key={index}>
                <img src={value.image} />
                <h2>{value.name}</h2>
                <p className="description">{value.description}</p>
                <p className="price">${value.price}</p>
              </div>
            )
          })
        : 'No products'}
    </div>
  )
}

We have a way to add products in the AddProduct component. Here we can add a way to remove a product, which we can use in our testing to avoid having lots of sample data and no way to remove a product.

Let’s first add a button, that once clicked calls the deleteProduct() method on the component, and passes it the product index:

<button onClick={e => {
  deleteProduct(index)
  e.stopPropagation()
}}>ⓧ</button>

The onClick event handler has to call stopPropagation() on the event, otherwise the outer onClick handler (that manages the list item click) kicks in.

This is the deleteProduct function called in the event handler:

const deleteProduct = index => {
  props.deleteProduct(index)
}

In App.js, we add deleteProduct as a prop:

<Route
  exact
  path="/"
  render={() => (
    <ProductsList products={products} deleteProduct={deleteProduct} />
  )}
/>

Here’s the deleteProduct method implementation:

const deleteProduct = index => {
  let updatedProducts = [...products]
  updatedProducts = updatedProducts
    .slice(0, index)
    .concat(updatedProducts.slice(index + 1, updatedProducts.length))
  setProducts(updatedProducts)
}

It’s same as we did in the Bills project for bills.

Let’s make the list render a bit nicer with by adding this style to src/App.css :

.products-list {
  display: flex;
  padding-top: 30px;
}
.products-list div {
  width: 33%;

  box-sizing: border-box;
  padding: 30px;
  background-color: lightsalmon;
}

button {
  padding: 30px;
  font-size: 2rem;
}

.description,
.price {
  padding-top: 20px;
}

img {
  max-width: 200px;
}

Here’s our result:

The single product page

When a product in the list is clicked, we want to open the single product page.

How do we do so?

We add a click handler to the container div that holds the product card in the list:

import React from 'react'

export default props => {
  const deleteProduct = index => {
    props.deleteProduct(index)
  }

  return (
    <div className="products-list">
      {props.products
        ? props.products.map((value, index) => {
            return (
              <div
                key={index}
                onClick={() => props.history.push('/product/' + value.slug)}
              >
                <img src={value.image} />
                <h2>{value.name}</h2>
                <p className="description">{value.description}</p>
                <p className="price">${value.price}</p>
                <button onClick={() => deleteProduct(index)}>ⓧ</button>
              </div>
            )
          })
        : 'No products'}
    </div>
  )
}

using the history prop, like we did before. In App.js we must pass it to ProductsList :

<Route
  exact
  path="/"
  render={({ history }) => (
    <ProductsList
      products={products}
      deleteProduct={deleteProduct}
      history={history}
    />
  )}
/>

That’s it! Now when clicking a product the URL changes and we are shown that product page.

That page is empty though! We need to fill it. Open the src/components/SingleProduct.js file, and let’s add a template similar to the one we added to ProductsList , this time focused on a single product. We’ll get the products details in a product prop.

We must populate this prop based on the slug we see in the URL. This in done inline, in the App component JSX:

<Route
  path="/product/:slug"
  render={({ match }) => (
    <SingleProduct
      product={products.find(p => p.slug === match.params.slug)}
    />
  )}
/>

Once this is done, we can show the product data in the template:

import React from 'react'

export default props => {
  return (
    <div className="single-product">
      <img src={props.product.image} />
      <h2>{props.product.name}</h2>
      <p className="description">{props.product.description}</p>
      <p className="price">${props.product.price}</p>
    </div>
  )
}

Adding to the cart

In the single product page now we’ll add the “Add to cart” button, which once clicked will add the product into the shopping cart.

In the src/components/SingleProduct.js we add the button:

<button onClick={addToCart}>Add to cart</button>

which calls the addToCart method of the component when clicked:

const addToCart = () => {
  props.addToCart({ product: props.product, quantity: 1 })
}

which in turn calls the addToCart prop, passing the product object, and an integer that sets the quantity to be added to the cart.

This is the Route with the prop, in App:

<Route
  path="/product/:slug"
  render={({ match }) => (
    <SingleProduct
      product={products.find(p => p.slug === match.params.slug)}
      addToCart={addToCart}
    />
  )}
/>

and here’s a stub for addToCart :

const addToCart = ({ product, quantity }) => {
  console.log(product, quantity)
}

Verify that all is working (you should see a console log with the correct data you submitted when adding a product to the cart).

Now in the App component we add a new cart state property using hooks, which is initialized to an empty array:

const [cart, setCart] = useState([])

We use setCart() in the addToCart() method to update the cart when a product is entered. This time we check if we already have the product in the cart, and if so we update the quantity. Otherwise we just add the product as a new entry:

const addToCart = ({ product, quantity }) => {
  const index = cart.findIndex(
    itemInCart => itemInCart.product.slug === product.slug
  )

  let newCart = []

  if (index === -1) {
    //not existing
    newCart = [...cart, { product, quantity }]
  } else {
    quantity += cart[index].quantity
    newCart = cart
      .filter(item => item.product.slug !== product.slug)
      .concat({product, quantity})
  }

  setCart(newCart)
}

If the product is already existing in the cart, based on its slug, we increment its quantity. Otherwise, we add it as a new element.

This is plain JavaScript until the setCart() method call.

findIndex is an array method that returns the index of the first element in the array that satisfies the provided testing function.

You can check if all works fine by adding a console.log(newCart) at the end.

But let’s now visualize the cart, as we can’t expect customers to check out their order from the DevTools, right?

We need a Cart component that should appear only if there is something in the cart. I decide to put it on every page, just below the navigation links.

So we create a src/components/Cart.js file, and we include it in the src/App.js component, passing it the cart prop:

import Cart from './components/Cart.js'
//...

const App = () => {
  //...
  return (
    <Router>
      <div id="app">
        <aside>
          <Link to={`/`}>Products</Link>
          <Link to={`/add-product`}>Add product</Link>
        </aside>

        <main>
          <Cart cart={cart}/>
          //...
        </main>
      </div>
    </Router>
  )
}

Let’s dive into the Cart component code now.

We want to only show it when there is something in the cart:

import React from 'react'

export default props => {
  if (!props.cart.length) return ''

  return (
    <table className="cart">
    </table>
  )
}

then we create the table structure, and we iterate over the cart content, to show the details:

import React from 'react'

export default props => {
  if (!props.cart.length) return ''

  return (
    <table className="cart">
      <tbody>
        <tr>
          <td colSpan="3">
            <h2>Cart</h2>
          </td>
        </tr>
        <tr>
          <th>Item</th>
          <th>Quantity</th>
          <th>Total Price</th>
        </tr>

        {props.cart.map((value, index) => {
          return (
            <tr key={index}>
              <td>{value.product.name}</td>
              <td>{value.quantity}</td>
              <td>${value.quantity * value.product.price}</td>
            </tr>
          )
        })}
      </tbody>
    </table>
  )
}

and we add a little styling to src/App.css to make it look good:

table.cart tr,
table.cart th,
table.cart td {
  border: 1px solid #333;
  padding: 10px;
}
table.cart {
  margin: 0 auto;
}

table.cart button {
  padding: 10px 40px;
  background-color: lightgreen;
  border-color: gray;
}

Persisting data

The e-commerce site now works great! We can add a product, list the products, open the product details page, and also add a product to the shopping cart.

But when we reload the page, all the data goes away!

This is because we don’t have any way of persisting the data. It’s all volatile.

There are various ways to persist data. The simplest one is to use the Web Storage API provided by browsers and use either localStorage or sessionStorage (there are differences in how they work).

We choose to use localStorage because it’s more persistent than sessionStorage. Without going too fancy, we simply go and add calls to window.localStorage every time we update our state.

We handle all the state in the App component, in the functions

  • addProduct
  • deleteProduct
  • addToCart

At the end of each of those functions we call localStorage.setItem() with the corresponding data:

const addProduct = product => {
  const updatedProducts = [...products, product]
  setProducts(updatedProducts)
  localStorage.setItem('products', JSON.stringify(updatedProducts))
}

const deleteProduct = index => {
  let updatedProducts = [...products]
  updatedProducts = updatedProducts
    .slice(0, index)
    .concat(updatedProducts.slice(index + 1, updatedProducts.length))
  setProducts(updatedProducts)
  localStorage.setItem('products', JSON.stringify(updatedProducts))
}

const addToCart = ({ product, quantity }) => {
  const index = cart.findIndex(
    itemInCart => itemInCart.product.slug === product.slug
  )

  let newCart = []

  if (index === -1) {
    //not existing
    newCart = [...cart, { product, quantity }]
  } else {
    quantity += cart[index].quantity
    newCart = cart
      .filter(item => item.product.slug !== product.slug)
      .concat({ product, quantity })
  }
  setCart(newCart)
  localStorage.setItem('cart', JSON.stringify(newCart))
}

Then at startup time we need to update the state with the state we get from localStorage. We import useEffect() to do so, from react :

import React, { useState, useEffect } from 'react'

then we call it passing a second argument, [], which means our callback function (passed as first argument) is only called on mount, and not on update. See my hooks blog post for more info on useEffect.

useEffect(() => {
  setProducts(JSON.parse(localStorage.getItem('products')) || [])
  setCart(JSON.parse(localStorage.getItem('cart')) || [])
}, [])

You can go fancier with libraries that can do this for you or other complex code, but I like to keep things as simple as possible. Complex code is always going to hurt.

That’s it! Now the products and the cart works fine, and if we reload the page, the data persists!

The final code of the project is available at https://github.com/flaviocopes/react-ecommerce

Challenges for you

Here are the challenges I propose for the e-commerce app:

  1. make sure that when you add a product, the “slug” (the portion of the URL that identifies this product) is unique. Choose a way to do this, and execute it.
  2. add a button that when clicked removes an item from the cart
  3. Add some feedback after adding a product. It could be a visual indication and then clearing the form data. Or you can redirect back to the products list.
  4. Instead of adding 1 product when the “Add to cart” button is clicked, add a quantity select element, from 1 to 10 (or a text box), and add the specified quantity
  5. Implement checkout using Stripe Checkout and create a confirmation page.
  6. Handle the case of accessing the wrong url, for products that do not exist. Add an empty state
1 Like