├── .gitignore ├── README.md ├── cypress.json ├── cypress ├── fixtures │ └── example.json ├── integration │ ├── checkout.spec.js │ ├── detail.spec.js │ └── products.spec.js ├── plugins │ └── index.js └── support │ ├── commands.js │ ├── index.d.ts │ └── index.js ├── db.json ├── package-lock.json ├── package.json ├── public ├── favicon.ico ├── images │ ├── logo.png │ ├── shoe1.jpg │ ├── shoe2.jpg │ ├── shoe3.jpg │ └── shoe4.jpg ├── index.html └── robots.txt └── src ├── App.css ├── App.jsx ├── App.test.js ├── Cart.jsx ├── Checkout.jsx ├── Detail.jsx ├── ErrorBoundary.jsx ├── Footer.jsx ├── Header.jsx ├── PageNotFound.jsx ├── Products.jsx ├── Spinner.jsx ├── index.js ├── services ├── productService.js ├── shippingService.js ├── useFetch.js └── useFetchAll.js ├── setupTests.js └── useLocalStorage.js /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This app showcases eight ways to handle React state. 2 | 3 | ![8 ways to handle React state](https://user-images.githubusercontent.com/1688997/85997509-2fd3cd00-b9cf-11ea-8708-76a33f695e70.png) 4 | 5 | Each branch implements the same features using different approaches. 6 | 7 | | Branch | State examples | 8 | | --------------------------------------------------------------------------------------------- | ---------------------------------------------------------- | 9 | | main | useState, useRef, web storage, lifted state, derived state | 10 | | [ref](https://github.com/coryhouse/react-state-final-demo-for-building-script/pull/6) | useRef for uncontrolled components | 11 | | [context](https://github.com/coryhouse/react-state-final-demo-for-building-script/pull/8) | context | 12 | | [immer](https://github.com/coryhouse/react-state-final-demo-for-building-script/pull/2) | immer | 13 | | [useReducer](https://github.com/coryhouse/react-state-final-demo-for-building-script/pull/1) | useReducer | 14 | | [class](https://github.com/coryhouse/react-state-final-demo-for-building-script/pull/3) | Class-based state example | 15 | | [react-query](https://github.com/coryhouse/react-state-final-demo-for-building-script/pull/7) | React query for remote state | 16 | 17 | ## Quick Start 18 | 19 | Make sure you have these installed: 20 | 21 | - [Node 10](http://nodejs.org) or newer (To check your version: `npm -v`) 22 | - [Git](http://git-scm.com) 23 | 24 | Then, run these commands on the command line: 25 | 26 | ``` 27 | npm install 28 | npm start 29 | ``` 30 | 31 | `npm start` starts the app and the mock API. 32 | 33 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 34 | -------------------------------------------------------------------------------- /cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "baseUrl": "http://localhost:3000/" 3 | } 4 | -------------------------------------------------------------------------------- /cypress/fixtures/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Using fixtures to represent data", 3 | "email": "hello@cypress.io", 4 | "body": "Fixtures are a great way to mock data for responses to routes" 5 | } -------------------------------------------------------------------------------- /cypress/integration/checkout.spec.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | context("Checkout", () => { 4 | it("should validate and support checkout", () => { 5 | cy.addToCart(1, 7); 6 | cy.findByRole("button", { name: "Checkout" }).click(); 7 | cy.findByLabelText("City").focus().blur(); 8 | cy.findByText("City is required."); 9 | cy.findByLabelText("Country").focus().blur(); 10 | cy.findByText("Country is required."); 11 | cy.findByRole("button", { name: "Submit" }).click(); 12 | 13 | // Now error summary at top should display 14 | cy.findByText("Please fix the following errors:"); 15 | 16 | // Now populate form and errors should disappear: 17 | cy.findByLabelText("City").type("a"); 18 | cy.findByLabelText("Country").select("China"); 19 | cy.findByText("City is required.").should("not.exist"); 20 | cy.findByText("Country is required.").should("not.exist"); 21 | 22 | // Now should submit successfully since form is completed. 23 | cy.findByRole("button", { name: "Submit" }).click(); 24 | cy.findByRole("heading", { name: "Thanks for shopping!" }); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /cypress/integration/detail.spec.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | function setQty(name, size, qty) { 4 | return cy 5 | .findByRole("combobox", { 6 | name: `Select quantity for ${name} size ${size}`, 7 | }) 8 | .select(qty.toString()); 9 | } 10 | 11 | function checkHead(name) { 12 | cy.findByRole("heading", { name }); 13 | } 14 | 15 | function checkQty(name, size, qty) { 16 | cy.findByRole("combobox", { 17 | name: `Select quantity for ${name} size ${size}`, 18 | }).should("have.value", qty.toString()); 19 | } 20 | 21 | context("Detail", () => { 22 | it("should support adding to cart after size is selected, then removing from cart", () => { 23 | cy.addToCart(1, 7); 24 | checkHead("1 Item in My Cart"); 25 | 26 | // Now, change quantity 27 | setQty("Hiker", 7, 2); 28 | checkHead("2 Items in My Cart"); 29 | 30 | // Now remove 31 | setQty("Hiker", 7, "Remove"); 32 | checkHead("Your cart is empty."); 33 | }); 34 | 35 | it("should increment the quantity in the cart when 'Add to cart' is clicked and the product is in the cart", () => { 36 | cy.addToCart(2, 8); 37 | checkHead("1 Item in My Cart"); 38 | cy.findByText("Continue Shopping").click(); 39 | cy.findByText("Climber").click(); 40 | cy.addToCart(2, 8); 41 | checkHead("2 Items in My Cart"); 42 | checkQty("Climber", 8, 2); 43 | }); 44 | 45 | it("should support adding the same item to the cart in different sizes, changing the quantity of each separately, and then support removing only one size of the same item", () => { 46 | cy.addToCart(3, 7); 47 | checkHead("1 Item in My Cart"); 48 | cy.findByText("Continue Shopping").click(); 49 | cy.findByText("Explorer").click(); 50 | cy.addToCart(3, 8); 51 | checkHead("2 Items in My Cart"); 52 | checkQty("Explorer", 7, 1); 53 | 54 | // Now change the quantity of each separately 55 | setQty("Explorer", 7, 3); 56 | setQty("Explorer", 7, 3); 57 | checkHead("4 Items in My Cart"); 58 | setQty("Explorer", 8, 2); 59 | checkQty("Explorer", 8, 2); 60 | checkHead("5 Items in My Cart"); 61 | 62 | // Now remove just the size 8 item 63 | setQty("Explorer", 8, "Remove"); 64 | checkHead("3 Items in My Cart"); 65 | 66 | // Assure the size 7 item's quantity hasn't been effected by removing the size 8 item. 67 | checkQty("Explorer", 7, 3); 68 | }); 69 | }); 70 | -------------------------------------------------------------------------------- /cypress/integration/products.spec.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | context("Products", () => { 4 | beforeEach(() => { 5 | cy.visit("/shoes"); 6 | }); 7 | 8 | it("should support filtering and clearing filter", () => { 9 | cy.findByLabelText("Filter by Size:").select("7"); 10 | cy.findByText("Found 2 items"); 11 | 12 | // Now clear filter 13 | cy.findByLabelText("Filter by Size:").select("All sizes"); 14 | cy.findByRole("heading", { name: "Found" }).should("not.exist"); 15 | }); 16 | 17 | it("should find 3 items when filtering for size 8", () => { 18 | cy.findByLabelText("Filter by Size:").select("8"); 19 | cy.findByText("Found 3 items"); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /cypress/plugins/index.js: -------------------------------------------------------------------------------- 1 | /// 2 | // *********************************************************** 3 | // This example plugins/index.js can be used to load plugins 4 | // 5 | // You can change the location of this file or turn off loading 6 | // the plugins file with the 'pluginsFile' configuration option. 7 | // 8 | // You can read more here: 9 | // https://on.cypress.io/plugins-guide 10 | // *********************************************************** 11 | 12 | // This function is called when a project is opened or re-opened (e.g. due to 13 | // the project's config changing) 14 | 15 | /** 16 | * @type {Cypress.PluginConfig} 17 | */ 18 | module.exports = (on, config) => { 19 | // `on` is used to hook into various events Cypress emits 20 | // `config` is the resolved Cypress config 21 | } 22 | -------------------------------------------------------------------------------- /cypress/support/commands.js: -------------------------------------------------------------------------------- 1 | // *********************************************** 2 | // This example commands.js shows you how to 3 | // create various custom commands and overwrite 4 | // existing commands. 5 | // 6 | // For more comprehensive examples of custom 7 | // commands please read more here: 8 | // https://on.cypress.io/custom-commands 9 | // *********************************************** 10 | // 11 | // 12 | // -- This is a parent command -- 13 | // Cypress.Commands.add("login", (email, password) => { ... }) 14 | // 15 | // 16 | // -- This is a child command -- 17 | // Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }) 18 | // 19 | // 20 | // -- This is a dual command -- 21 | // Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) 22 | // 23 | // 24 | // -- This will overwrite an existing command -- 25 | // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) 26 | import "@testing-library/cypress/add-commands"; 27 | 28 | Cypress.Commands.add("addToCart", (id, size) => { 29 | cy.visit("/shoes/" + id); 30 | cy.findByRole("button", { name: "Add to cart" }).should("be.disabled"); 31 | cy.findByLabelText("Select size").select(size.toString()); 32 | cy.findByRole("button", { name: "Add to cart" }).click(); 33 | }); 34 | -------------------------------------------------------------------------------- /cypress/support/index.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | declare namespace Cypress { 4 | interface Chainable { 5 | /** 6 | * Add a shoe to the cart 7 | * @example 8 | * cy.addToCart(id, size) 9 | */ 10 | addToCart(id: number, size: number): Chainable; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /cypress/support/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands' 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | -------------------------------------------------------------------------------- /db.json: -------------------------------------------------------------------------------- 1 | { 2 | "products": [ 3 | { 4 | "id": 1, 5 | "category": "shoes", 6 | "image": "shoe1.jpg", 7 | "name": "Hiker", 8 | "price": 94.95, 9 | "skus": [ 10 | { 11 | "sku": "17", 12 | "size": 7 13 | }, 14 | { 15 | "sku": "18", 16 | "size": 8 17 | } 18 | ], 19 | "description": "This rugged boot will get you up the mountain safely." 20 | }, 21 | { 22 | "id": 2, 23 | "category": "shoes", 24 | "image": "shoe2.jpg", 25 | "name": "Climber", 26 | "price": 78.99, 27 | "skus": [ 28 | { 29 | "sku": "28", 30 | "size": 8 31 | }, 32 | { 33 | "sku": "29", 34 | "size": 9 35 | } 36 | ], 37 | "description": "Sure-footed traction in slippery conditions." 38 | }, 39 | { 40 | "id": 3, 41 | "category": "shoes", 42 | "image": "shoe3.jpg", 43 | "name": "Explorer", 44 | "price": 145.95, 45 | "skus": [ 46 | { 47 | "sku": "37", 48 | "size": 7 49 | }, 50 | { 51 | "sku": "38", 52 | "size": 8 53 | }, 54 | { 55 | "sku": "39", 56 | "size": 9 57 | } 58 | ], 59 | "description": "Look stylish while stomping in the mud." 60 | } 61 | ], 62 | "shippingAddress": [ 63 | { 64 | "id": 1, 65 | "city": "", 66 | "country": "" 67 | }, 68 | { 69 | "city": "a", 70 | "country": "China", 71 | "id": 2 72 | }, 73 | { 74 | "city": "a", 75 | "country": "China", 76 | "id": 3 77 | } 78 | ] 79 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-state", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/cypress": "^6.0.0", 7 | "@testing-library/jest-dom": "^4.2.4", 8 | "@testing-library/react": "^9.3.2", 9 | "@testing-library/user-event": "^7.1.2", 10 | "@xstate/react": "^0.8.1", 11 | "cross-env": "^7.0.2", 12 | "cypress": "^4.10.0", 13 | "eslint-plugin-cypress": "^2.11.1", 14 | "history": "^5.0.0", 15 | "immer": "^7.0.5", 16 | "react": "^16.13.1", 17 | "react-dom": "^16.13.1", 18 | "react-query": "^2.4.14", 19 | "react-query-devtools": "^2.3.0", 20 | "react-router-dom": "^6.0.0-beta.0", 21 | "react-scripts": "3.4.1", 22 | "start-server-and-test": "^1.11.1", 23 | "xstate": "^4.10.0" 24 | }, 25 | "scripts": { 26 | "start": "run-p start-app start-api", 27 | "start-app": "cross-env REACT_APP_API_BASE_URL=http://localhost:3001/ react-scripts start", 28 | "start-api": "json-server --port 3001 --watch db.json --delay 0", 29 | "build": "react-scripts build", 30 | "test": "react-scripts test", 31 | "eject": "react-scripts eject", 32 | "cy-open": "cypress open", 33 | "cy": "start-test 3000 cy-open" 34 | }, 35 | "eslintConfig": { 36 | "parser": "babel-eslint", 37 | "extends": [ 38 | "react-app", 39 | "plugin:react/recommended" 40 | ], 41 | "env": { 42 | "es6": true, 43 | "jest": true, 44 | "cypress/globals": true 45 | }, 46 | "plugins": [ 47 | "react", 48 | "cypress" 49 | ], 50 | "parserOptions": { 51 | "sourceType": "module" 52 | }, 53 | "rules": { 54 | "react/prop-types": 0 55 | } 56 | }, 57 | "browserslist": { 58 | "production": [ 59 | ">0.2%", 60 | "not dead", 61 | "not op_mini all" 62 | ], 63 | "development": [ 64 | "last 1 chrome version", 65 | "last 1 firefox version", 66 | "last 1 safari version" 67 | ] 68 | }, 69 | "devDependencies": { 70 | "json-server": "^0.16.1", 71 | "npm-run-all": "^4.1.5" 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coryhouse/react-state-demo/64e0b07bbe9b6d0b263f92dc54010782f976be9f/public/favicon.ico -------------------------------------------------------------------------------- /public/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coryhouse/react-state-demo/64e0b07bbe9b6d0b263f92dc54010782f976be9f/public/images/logo.png -------------------------------------------------------------------------------- /public/images/shoe1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coryhouse/react-state-demo/64e0b07bbe9b6d0b263f92dc54010782f976be9f/public/images/shoe1.jpg -------------------------------------------------------------------------------- /public/images/shoe2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coryhouse/react-state-demo/64e0b07bbe9b6d0b263f92dc54010782f976be9f/public/images/shoe2.jpg -------------------------------------------------------------------------------- /public/images/shoe3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coryhouse/react-state-demo/64e0b07bbe9b6d0b263f92dc54010782f976be9f/public/images/shoe3.jpg -------------------------------------------------------------------------------- /public/images/shoe4.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coryhouse/react-state-demo/64e0b07bbe9b6d0b263f92dc54010782f976be9f/public/images/shoe4.jpg -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | React App 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | html { 2 | height: 100%; 3 | } 4 | 5 | body { 6 | font-family: open sans, sans-serif; 7 | font-size: 14px; 8 | line-height: 1.42857143; 9 | margin: 0; 10 | padding: 0; 11 | color: #364147; 12 | background-color: #fff; 13 | } 14 | 15 | #root { 16 | display: flex; 17 | flex-direction: column; 18 | height: 100vh; /* Avoid the IE 10-11 `min-height` bug. */ 19 | } 20 | 21 | .content { 22 | flex: 1 0 auto; 23 | padding: 20px; 24 | } 25 | 26 | header { 27 | display: flex; 28 | padding-left: 20px; 29 | } 30 | 31 | header img { 32 | max-height: 100px; 33 | } 34 | 35 | nav { 36 | display: flex; 37 | align-items: center; 38 | } 39 | 40 | footer { 41 | text-align: center; 42 | color: gray; 43 | padding: 20px 0 20px 0; 44 | flex-shrink: 0; 45 | background-color: gainsboro; 46 | } 47 | 48 | header ul { 49 | padding-inline-start: 20px; 50 | } 51 | 52 | header li { 53 | display: inline; 54 | margin-right: 20px; 55 | } 56 | 57 | #detail img { 58 | max-height: 400; 59 | } 60 | 61 | #price { 62 | margin-top: 20px; 63 | margin-bottom: 10px; 64 | font-weight: normal; 65 | font-size: 1.5em; 66 | } 67 | 68 | #products { 69 | display: flex; 70 | justify-content: center; 71 | } 72 | 73 | .product { 74 | display: flex; 75 | align-items: flex-end; 76 | max-width: 350px; 77 | text-align: center; 78 | } 79 | 80 | #cart img { 81 | height: 150px; 82 | } 83 | 84 | #cart ul { 85 | padding: 0; 86 | list-style-type: none; 87 | } 88 | 89 | #cart td, 90 | #cart th { 91 | padding-left: 20px; 92 | padding-right: 20px; 93 | } 94 | 95 | input[type="number"] { 96 | width: 50px; 97 | } 98 | 99 | main { 100 | padding: 20px; 101 | } 102 | 103 | .cart-item { 104 | display: flex; 105 | margin-bottom: 20px; 106 | } 107 | 108 | a { 109 | color: #faa541; 110 | text-decoration: none; 111 | } 112 | 113 | h1 { 114 | font-size: 36px; 115 | } 116 | 117 | h3 { 118 | font-size: 24px; 119 | } 120 | 121 | h1, 122 | h2, 123 | h3 { 124 | margin-top: 20px; 125 | margin-bottom: 10px; 126 | font-weight: normal; 127 | } 128 | 129 | img { 130 | max-width: 100%; 131 | height: auto; 132 | border: 0; 133 | vertical-align: middle; 134 | } 135 | 136 | .btn-primary { 137 | color: #fff; 138 | background-color: #364147; 139 | } 140 | 141 | .btn { 142 | display: inline-block; 143 | margin-bottom: 0; 144 | font-weight: 400; 145 | text-align: center; 146 | vertical-align: middle; 147 | -ms-touch-action: manipulation; 148 | touch-action: manipulation; 149 | cursor: pointer; 150 | background-image: none; 151 | border: 1px solid transparent; 152 | white-space: nowrap; 153 | padding: 6px 12px; 154 | font-size: 14px; 155 | line-height: 1.42857143; 156 | border-radius: 3px; 157 | -webkit-user-select: none; 158 | -moz-user-select: none; 159 | -ms-user-select: none; 160 | user-select: none; 161 | } 162 | 163 | .btn.disabled, 164 | .btn[disabled], 165 | fieldset[disabled] .btn { 166 | cursor: not-allowed; 167 | opacity: 0.65; 168 | filter: alpha(opacity=65); 169 | -webkit-box-shadow: none; 170 | box-shadow: none; 171 | } 172 | 173 | p[role="alert"] { 174 | display: inline; 175 | margin-left: 10px; 176 | color: red; 177 | } 178 | 179 | div[role="alert"] { 180 | color: red; 181 | } 182 | 183 | /* input[type="submit"] { 184 | margin-top: 20px; 185 | } */ 186 | 187 | form > div { 188 | margin-bottom: 20px; 189 | } 190 | 191 | .lds-container { 192 | display: flex; 193 | align-items: center; 194 | justify-content: center; 195 | } 196 | .lds-dual-ring { 197 | display: inline-block; 198 | width: 80px; 199 | height: 80px; 200 | } 201 | .lds-dual-ring:after { 202 | content: " "; 203 | display: block; 204 | width: 64px; 205 | height: 64px; 206 | margin: 8px; 207 | border-radius: 50%; 208 | border: 6px solid #cef; 209 | border-color: #cef transparent #cef transparent; 210 | animation: lds-dual-ring 1.2s linear infinite; 211 | } 212 | @keyframes lds-dual-ring { 213 | 0% { 214 | transform: rotate(0deg); 215 | } 216 | 100% { 217 | transform: rotate(360deg); 218 | } 219 | } 220 | -------------------------------------------------------------------------------- /src/App.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import "./App.css"; 3 | import Footer from "./Footer"; 4 | import Header from "./Header"; 5 | import Products from "./Products"; 6 | import { Route, Routes } from "react-router-dom"; 7 | import Detail from "./Detail"; 8 | import Cart from "./Cart"; 9 | import Checkout from "./Checkout"; 10 | 11 | function App() { 12 | const [cart, setCart] = useState(() => { 13 | try { 14 | return JSON.parse(localStorage.getItem("cart")) ?? []; 15 | } catch { 16 | console.error( 17 | "The localStorage cart could not be parsed into JSON. Resetting to an empty array." 18 | ); 19 | return []; 20 | } 21 | }); 22 | 23 | // Persist cart in localStorage 24 | useEffect(() => localStorage.setItem("cart", JSON.stringify(cart)), [cart]); 25 | 26 | function addToCart(id, sku) { 27 | setCart((items) => { 28 | const alreadyInCart = items.find((i) => i.sku === sku); 29 | if (alreadyInCart) { 30 | // Return new array with matching item replaced 31 | return items.map((i) => 32 | i.sku === sku ? { ...i, quantity: i.quantity + 1 } : i 33 | ); 34 | } else { 35 | // Return new array with new item appended 36 | return [...items, { id, sku, quantity: 1 }]; 37 | } 38 | }); 39 | } 40 | 41 | function updateQuantity(sku, quantity) { 42 | setCart((items) => { 43 | return quantity === 0 44 | ? items.filter((i) => i.sku !== sku) 45 | : items.map((i) => (i.sku === sku ? { ...i, quantity } : i)); 46 | }); 47 | } 48 | 49 | function emptyCart() { 50 | setCart([]); 51 | } 52 | 53 | const numItemsInCart = cart.reduce((total, item) => total + item.quantity, 0); 54 | 55 | return ( 56 | <> 57 |
58 |
59 | 60 |
61 | 62 | Welcome to Carved Rock Fitness} /> 63 | 71 | } 72 | /> 73 | } 76 | /> 77 | } /> 78 | } 81 | /> 82 | 83 | 84 |
85 |
86 |