Building an Ice Cream App: A Full-Stack Tutorial Using Express, Mongo/Mongoose, and React - Part 2/2
Welcome to the second part of our tutorial on building an Ice Cream App from scratch! In the first part, we covered the backend side of things, using Express and Mongo/Mongoose to create a RESTful API for managing ice cream orders. Now, it's time to focus on the frontend and build a user interface that connects to our backend API.
In this tutorial, we'll be using React to create a modern, responsive web application that allows users to browse ice cream flavors, place orders, and see their order history. We'll also be using Sass for styling and React Router for navigation. By the end of this tutorial, you'll have a full-stack web app that you can customize and deploy to a live server. So let's get started and build an awesome ice cream app together!
Setup
To set up your project, first, navigate to the frontend folder in your terminal. Then, install React Router and Sass by running the command:
npm install react-router-dom sass
Next, create a file named styles.scss in the src folder.
React-Router allows us to generate routes, loaders, and actions, which we can use in our application:
To keep track of these create three files in your src folder
Now, let's move on to the router.js file where we can set up our router. This file is where we'll define the routes for our application and specify which components should be rendered for each route.
import {
createBrowserRouter,
createRoutesFromElements,
Route,
} from "react-router-dom"
import App from "./App"
const router = createBrowserRouter(
createRoutesFromElements(<Route path="/" element={<App />}></Route>)
)
export default router
Update index.js to look like this:
import React from "react"
import ReactDOM from "react-dom/client"
import "./styles.scss"
import reportWebVitals from "./reportWebVitals"
import { RouterProvider } from "react-router-dom"
import router from "./router"
const root = ReactDOM.createRoot(document.getElementById("root"))
root.render(
<React.StrictMode>
<RouterProvider router={router} />
</React.StrictMode>
)
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals()
function Component(props) {
return <h1>Component Name</h1>
}
export default Component
Testing at Every Step
Testing is an essential part of software development that ensures you don't end up with a wobbly 1000-story building. Imagine building your dream skyscraper, one floor at a time, but you're in a hurry to reach the top. You keep adding more floors without checking the foundation or the structure, until you finally reach the 1000th floor and suddenly, your building starts shaking. You have no idea where to begin fixing it and it feels like you're stuck in a Jenga game gone wrong. This is where testing comes in! By testing early and often, you can make sure that each floor is sturdy and secure before moving on to the next one.
App.js
Our desired component Architecture:
-> App
-> Header
-> Outlet
-> Route |path: "/"|
-> Index |loads all ice creams|
-> Route |path="/icecreamse/:id|
-> Show |loads single ice cream|
-> Route |path: "/create"| Action to create ice creams
-> Route |path: "/update/:id"| Action to update ice creams
-> Route |path: "/delete/:id"| Action to delete ice creams
Let's add our routes to router.js
import {
createBrowserRouter,
createRoutesFromElements,
Route,
} from "react-router-dom"
import App from "./App"
import Index from "./pages/Index"
import Show from "./pages/Show"
const router = createBrowserRouter(
createRoutesFromElements(
<Route path="/" element={<App />}>
<Route path="" element={<Index />} />
<Route path=":id" element={<Show />} />
<Route path="create" />
<Route path="update/:id" />
<Route path="delete/:id" />
</Route>
)
)
export default router
In React, the <Outlet> component is used in conjunction with the <Route> component to render the appropriate component based on the current URL. It serves as a placeholder where the matched components will be rendered.
Add the following to App.js:
import { Outlet } from "react-router-dom"
import Header from "./components/Header"
function App() {
return (
<div className="App">
<Header />
<Outlet />
</div>
)
}
export default App
Setting Up Navigation
In Header.js, let's include the following:
import { Link } from "react-router-dom"
function Header(props) {
return (
<nav className="nav">
<Link to="/">
<div>Ice Cream App</div>
</Link>
</nav>
)
}
export default Header
Inside the Header component, a nav element with a class of "nav" is created to wrap the navigation links. Within the nav element, a Link component is used to create a clickable link to the root path of the application ("/" in this case).
Sass Styling
Sass is a tool that enables developers to write CSS code in a more advanced way, introducing new features such as nested selectors, mixin, and variables. Let's add some basic Sass in our styles.scss!
// Define SASS variables
$primary-color: #4d4d4d;
$secondary-color: #fff;
$accent-color: #f00;
$gray-color: #888;
// --------------------------
// Header
// --------------------------
nav {
background-color: $primary-color;
color: $secondary-color;
display: flex;
justify-content: flex-start;
a {
color: $secondary-color;
text-decoration: none;
display: flex;
align-items: center;
div {
margin: 10px;
font-size: 24px;
font-weight: bold;
}
}
}
Displaying Ice Creams in Index
First we will give the index route a loader to load all the ice creams who should be listed. This is how loaders.js should look like:
const URL = "http://localhost:4000"
export const iceCreamsLoader = async () => {
const response = await fetch(URL + "/icecreams")
const icecreams = await response.json()
return icecreams
}
Although you can substitute the URL with the deployed version of your API, you should be aware that if you're using the free tier, it could be frustratingly slow. That's why, when working on a hobby project, I prefer running the API server locally during development.
Then attach this loader to our route in router.js
import {
createBrowserRouter,
createRoutesFromElements,
Route,
} from "react-router-dom"
import App from "./App"
import { iceCreamsLoader } from "./loaders"
import Index from "./pages/Index"
import Show from "./pages/Show"
const router = createBrowserRouter(
createRoutesFromElements(
<Route path="/" element={<App />}>
<Route path="" element={<Index />} loader={iceCreamsLoader}/>
<Route path=":id" element={<Show />} />
<Route path="create" />
<Route path="update/:id" />
<Route path="delete/:id" />
</Route>
)
)
export default router
Next, let's display our ice creams in Index.js
import { Link, useLoaderData } from "react-router-dom"
function Index (props) {
const icecreams = useLoaderData()
return (
<div className="icecream-list">
{icecreams.map(icecream =>(
<div key={icecream.id} className="icecream">
<Link to={`/${icecream.id}`}>
<h2>{icecream.name}</h2>
</Link>
<p>{icecream.description}</p>
<img src={icecream.image} alt={icecream.name} />
</div>
))}
</div>
)
}
export default Index
At this point, you should be able to see ice creams data displayed on the index page. If you are having trouble fetching the data, make sure your server is running.
Let's incorporate additional styling to enhance the appearance of our Index page. Add the following to styles.scss:
Recommended by LinkedIn
// --------------------------
// Index
// --------------------------
/* Define a style for the ice cream list */
$margin-top: 25px;
$box-padding: 20px;
$border-radius: 10px;
$box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
$box-width: 300px;
.icecream-list {
margin-top: $margin-top;
display: flex;
flex-wrap: wrap;
justify-content: left;
align-items: center;
flex-direction: row;
}
/* Define a style for the individual ice cream items */
.icecream {
margin: $margin-top/2;
padding: $box-padding;
border-radius: $border-radius;
box-shadow: $box-shadow;
background-color: $secondary-color;
max-width: $box-width;
width: $box-width;
}
/* Define a style for the ice cream image */
.icecream img {
width: 100%;
height: auto;
border-radius: 5px;
margin-bottom: 10px;
}
/* Define a style for the ice cream name */
.icecream h2 {
font-size: 24px;
margin-bottom: 10px;
}
/* Define a style for the ice cream description */
.icecream p {
font-size: 16px;
margin-bottom: 10px;
color: $gray-color;
}
/* Define a style for the ice cream link */
.icecream a {
color: #555;
text-decoration: none;
transition: all 0.3s ease;
}
.icecream a:hover {
color: $accent-color;
}
Creating Ice Creams
Up until this point, we have been using Postman to add ice creams. Let's switch things up and add a form to our Index.js file. To do this, we'll make use of the react-router form component which will trigger a route action upon submission. Our goal is to create an action that will take the form data and use it to create a new ice cream entry within our API.
Index.js
import { Form, Link, useLoaderData } from "react-router-dom"
function Index (props) {
const icecreams = useLoaderData()
return (
<>
<div className="create">
<Form action="/create" method="post">
<input type="input" name="name" placeholder="Ice Cream Name" />
<input type="input" name="description" placeholder="Ice Cream Description" />
<input type="input" name="image" placeholder="Ice Cream Image URL" />
<input type="submit" value="Create Ice Cream" />
</Form>
</div>
<div className="icecream-list">
{icecreams.map(icecream =>(
<div key={icecream.id} className="icecream">
<Link to={`/${icecream.id}`}>
<h2>{icecream.name}</h2>
</Link>
<p>{icecream.description}</p>
<img src={icecream.image} alt={icecream.name} />
</div>
))}
</div>
</>
)
}
export default Index
In React, the <> and </> allow you to group a list of children without adding extra nodes to the DOM. We use the <> and </> to wrap the entire component's JSX elements. This is because in JSX, a component must return a single parent element, and using the fragment syntax allows you to return multiple elements without needing to wrap them in a single parent element like a <div> or <span>.
Next, we should define an action to handle the submission of the form when the /create route is accessed. To do so, let's incorporate the following code into the actions.js file:
import { redirect } from "react-router-dom";
const URL = "http://localhost:4000"
export const createAction = async ({ request }) => {
// get data from form
const formData = await request.formData()
// set up our new ice cream to match schema
const newIceCream = {
name: formData.get("name"),
description: formData.get("description"),
image: formData.get("image"),
}
// send new ice cream to API
await fetch(URL + "/icecreams", {
method: "post",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(newIceCream),
})
// redirect to index
return redirect("/")
}
Now let's attach this action to the right route in router.js:
import {
createBrowserRouter,
createRoutesFromElements,
Route,
} from "react-router-dom"
import App from "./App"
import { iceCreamsLoader } from "./loaders"
import Index from "./pages/Index"
import Show from "./pages/Show"
import { createAction } from "./actions"
const router = createBrowserRouter(
createRoutesFromElements(
<Route path="/" element={<App />}>
<Route path="" element={<Index />} loader={iceCreamsLoader}/>
<Route path=":id" element={<Show />} />
<Route path="create" action={createAction}/>
<Route path="update/:id" />
<Route path="delete/:id" />
</Route>
)
)
export default router
You should now be able to see all the ice creams and create ice creams! Let's add some styling to the form. add the following to styles.sass
// --------------------------
// Create Form
// --------------------------
.create {
display: flex;
flex-direction: column;
align-items: center;
margin-top: 25px;
}
input {
font-size: 1rem;
padding: 0.5rem;
margin: 0.5rem;
border: none;
border-radius: 0.25rem;
box-shadow: 0 0 5px rgba(0, 0, 0, 0.2);
}
input[type="submit"] {
background-color: $gray-color;
color: $secondary-color;
cursor: pointer;
}
input[type="submit"]:hover {
background-color: $primary-color;
}
input:focus {
outline: none;
box-shadow: 0 0 5px rgba(255, 0, 0, 0.2);
}
The Show Page
Let's make the links clickable to take the user to the corresponding show page. We need to make some changes to enable this functionality.
To begin with, we need to add a loader for the show route, which we can do in the loaders.js file.
export const iceCreamLoader = async ({params}) => {
const response = await fetch(URL + "/icecreams/" + params.id)
const icecream = await response.json()
return icecream
}
Let's attach the loader to the route in router.js
import {
createBrowserRouter,
createRoutesFromElements,
Route,
} from "react-router-dom"
import App from "./App"
import { iceCreamLoader, iceCreamsLoader } from "./loaders"
import Index from "./pages/Index"
import Show from "./pages/Show"
import { createAction } from "./actions"
const router = createBrowserRouter(
createRoutesFromElements(
<Route path="/" element={<App />}>
<Route path="" element={<Index />} loader={iceCreamsLoader}/>
<Route path=":id" element={<Show />} loader={iceCreamLoader}/>
<Route path="create" action={createAction}/>
<Route path="update/:id" />
<Route path="delete/:id" />
</Route>
)
)
export default router
Next, let's build our Show component in Show.js
import { useLoaderData } from "react-router-dom"
function Show(props) {
const iceCream = useLoaderData()
console.log(`ice cream:`, iceCream)
return (
<div className="show">
<h2>{iceCream.name}</h2>
<p>{iceCream.description}</p>
<img src={iceCream.image} alt={iceCream.name}/>
</div>
)
}
export default Show
You have successfully built the basic structure of the Show component. Now is the perfect time to get creative and add your own styling to styles.scss to make your ice cream app look amazing! Experiment with different colors, fonts, and layouts to make your app stand out. Happy styling!
Updating Ice Cream
Show.js
import { Form, useLoaderData } from "react-router-dom"
function Show(props) {
const iceCream = useLoaderData()
return (
<div className="show">
<h2>{iceCream.name}</h2>
<p>{iceCream.description}</p>
<img src={iceCream.image} alt={iceCream.name} />
<h2>Updtae {iceCream.name}</h2>
<Form action={`/update/${iceCream._id}`} method="post">
<input type="input" name="name" placeholder="Ice Cream Name" defaultValue={iceCream.name}/>
<input type="input" name="description" placeholder="Ice Cream Description" defaultValue={iceCream.description}/>
<input type="input" name="image" placeholder="Ice Cream Image URL" defaultValue={iceCream.image}/>
<input type="submit" value={`update ${iceCream.name}`} />
</Form>
</div>
)
}
export default Show
Now let's add an action to handle the update to actions.js
export const updateAction = async ({request, params}) => {
// get data from form
const formData = await request.formData()
// set up our new ice cream to match schema
const updatedIceCream = {
name: formData.get("name"),
image: formData.get("image"),
description: formData.get("description")
}
// Send new ice cream to our API
await fetch(URL + "/icecreams/" + params.id, {
method: "put",
headers: {
"Content-Type":"application/json"
},
body: JSON.stringify(updatedIceCream)
})
// redirect to index
return redirect("/")
}
Lastly, add theupdateAction
updateAction
Lastly, add updateAction to the create route in router.js and test your new form!
import {
createBrowserRouter,
createRoutesFromElements,
Route,
} from "react-router-dom"
import App from "./App"
import { iceCreamLoader, iceCreamsLoader } from "./loaders"
import Index from "./pages/Index"
import Show from "./pages/Show"
import { createAction, updateAction } from "./actions"
const router = createBrowserRouter(
createRoutesFromElements(
<Route path="/" element={<App />}>
<Route path="" element={<Index />} loader={iceCreamsLoader}/>
<Route path=":id" element={<Show />} loader={iceCreamLoader}/>
<Route path="create" action={createAction}/>
<Route path="update/:id" action={updateAction}/>
<Route path="delete/:id" />
</Route>
)
)
export default router
Delete Ice Cream
We can easily incorporate a delete button by using a Form and following a similar pattern.
Show.js
import { Form, useLoaderData } from "react-router-dom"
function Show(props) {
const iceCream = useLoaderData()
return (
<div className="show">
<h2>{iceCream.name}</h2>
<p>{iceCream.description}</p>
<img src={iceCream.image} alt={iceCream.name} />
<h2>Updtae {iceCream.name}</h2>
<Form action={`/update/${iceCream._id}`} method="post">
<input type="input" name="name" placeholder="Ice Cream Name" defaultValue={iceCream.name}/>
<input type="input" name="description" placeholder="Ice Cream Description" defaultValue={iceCream.description}/>
<input type="input" name="image" placeholder="Ice Cream Image URL" defaultValue={iceCream.image}/>
<input type="submit" value={`update ${iceCream.name}`} />
</Form>
<h2>Delete {iceCream.name}</h2>
<Form action={`/delete/${iceCream._id}`} method="post">
<input type="submit" value={`delete ${iceCream.name}`} />
</Form>
</div>
)
}
export default Show
Create deleteAction in action.js
export const deleteAction = async ({params}) => {
// Send delete request to our API
await fetch(URL + "/icecreams/" + params.id, {
method: "delete"
})
// redirect to index
return redirect("/")
}
Attach the action deleteAction to our delete/:id route in router.js
import {
createBrowserRouter,
createRoutesFromElements,
Route,
} from "react-router-dom"
import App from "./App"
import { iceCreamLoader, iceCreamsLoader } from "./loaders"
import Index from "./pages/Index"
import Show from "./pages/Show"
import { createAction, deleteAction, updateAction } from "./actions"
const router = createBrowserRouter(
createRoutesFromElements(
<Route path="/" element={<App />}>
<Route path="" element={<Index />} loader={iceCreamsLoader}/>
<Route path=":id" element={<Show />} loader={iceCreamLoader}/>
<Route path="create" action={createAction}/>
<Route path="update/:id" action={updateAction}/>
<Route path="delete/:id" action={deleteAction}/>
</Route>
)
)
export default router
Congratulations! CRUD functionality should be complete!
Deployment
Now that we are deploying the React app and aiming to make the data accessible from anywhere, it's advisable to switch from running your API locally to the deployed version. For sensitive or paid APIs, it's common practice to hide the link in an environment variable, so that the link is not exposed in the code. However, for an educational project like ours, this may not be necessary. You can simply update the URL to the deployed API link.
I hope that this tutorial has been informative and helpful in guiding you through the process of building your own ice cream app from scratch. By combining the backend and frontend skills learned in both parts of this tutorial, you have all the tools necessary to create a fully functional web app that you can deploy and share with others. Remember to continue exploring and experimenting with different tools and technologies to enhance the functionality and user experience of your app. Happy coding and enjoy your sweet creations!
Fantastic walk through Ayelet! Excited to see what is on the horizon for you and your upcoming content.