├── public ├── robots.txt ├── favicon.ico ├── logo192.png ├── logo512.png ├── manifest.json └── index.html ├── src ├── mocks │ ├── worker.js │ ├── server.js │ ├── data.js │ └── handlers.js ├── index.js ├── components │ ├── Header.js │ ├── Item.js │ ├── App.js │ ├── Filter.js │ ├── ItemForm.js │ └── ShoppingList.js ├── index.css └── __tests__ │ └── ShoppingList.test.js ├── babel.config.js ├── .canvas ├── .gitignore ├── db.json ├── .github └── workflows │ └── canvas-sync-ruby-update.yml ├── package.json ├── LICENSE.md ├── CONTRIBUTING.md └── README.md /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/learn-co-curriculum/react-hooks-fetch-crud-code-along/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/learn-co-curriculum/react-hooks-fetch-crud-code-along/HEAD/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/learn-co-curriculum/react-hooks-fetch-crud-code-along/HEAD/public/logo512.png -------------------------------------------------------------------------------- /src/mocks/worker.js: -------------------------------------------------------------------------------- 1 | import { setupWorker } from 'msw' 2 | import { handlers } from './handlers' 3 | 4 | export const worker = setupWorker(...handlers) -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | "@babel/preset-env", 4 | ["@babel/preset-react", { runtime: "automatic" }], 5 | ], 6 | }; 7 | -------------------------------------------------------------------------------- /src/mocks/server.js: -------------------------------------------------------------------------------- 1 | import { setupServer } from "msw/node"; 2 | import { handlers } from "./handlers"; 3 | 4 | export const server = setupServer(...handlers); 5 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import "./index.css"; 4 | import App from "./components/App"; 5 | 6 | ReactDOM.render(, document.getElementById("root")); 7 | -------------------------------------------------------------------------------- /.canvas: -------------------------------------------------------------------------------- 1 | --- 2 | :lessons: 3 | - :id: 224981 4 | :course_id: 6667 5 | :canvas_url: https://learning.flatironschool.com/courses/6667/assignments/224981 6 | :type: assignment 7 | - :id: 263399 8 | :course_id: 7553 9 | :canvas_url: https://learning.flatironschool.com/courses/7553/assignments/263399 10 | :type: assignment 11 | -------------------------------------------------------------------------------- /src/components/Header.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | function Header({ isDarkMode, onDarkModeClick }) { 4 | return ( 5 |
6 |

Shopster

7 | 10 |
11 | ); 12 | } 13 | 14 | export default Header; 15 | -------------------------------------------------------------------------------- /src/mocks/data.js: -------------------------------------------------------------------------------- 1 | export const data = [ 2 | { 3 | id: 1, 4 | name: "Yogurt", 5 | category: "Dairy", 6 | isInCart: false, 7 | }, 8 | { 9 | id: 2, 10 | name: "Pomegranate", 11 | category: "Produce", 12 | isInCart: false, 13 | }, 14 | { 15 | id: 3, 16 | name: "Lettuce", 17 | category: "Produce", 18 | isInCart: false, 19 | }, 20 | ]; 21 | -------------------------------------------------------------------------------- /src/components/Item.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | function Item({ item }) { 4 | return ( 5 |
  • 6 | {item.name} 7 | {item.category} 8 | 11 | 12 |
  • 13 | ); 14 | } 15 | 16 | export default Item; 17 | -------------------------------------------------------------------------------- /.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 | 25 | # Learn-specific .results.json 26 | .results.json 27 | 28 | # Ignore ESLint files 29 | .eslintcache 30 | -------------------------------------------------------------------------------- /src/components/App.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import ShoppingList from "./ShoppingList"; 3 | import Header from "./Header"; 4 | 5 | function App() { 6 | const [isDarkMode, setIsDarkMode] = useState(false); 7 | 8 | function handleDarkModeClick() { 9 | setIsDarkMode((isDarkMode) => !isDarkMode); 10 | } 11 | 12 | return ( 13 |
    14 |
    15 | 16 |
    17 | ); 18 | } 19 | 20 | export default App; 21 | -------------------------------------------------------------------------------- /src/components/Filter.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | function Filter({ category, onCategoryChange }) { 4 | return ( 5 |
    6 | 16 |
    17 | ); 18 | } 19 | 20 | export default Filter; 21 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /db.json: -------------------------------------------------------------------------------- 1 | { 2 | "items": [ 3 | { 4 | "id": 1, 5 | "name": "Yogurt", 6 | "category": "Dairy", 7 | "isInCart": false 8 | }, 9 | { 10 | "id": 2, 11 | "name": "Pomegranate", 12 | "category": "Produce", 13 | "isInCart": false 14 | }, 15 | { 16 | "id": 3, 17 | "name": "Lettuce", 18 | "category": "Produce", 19 | "isInCart": false 20 | }, 21 | { 22 | "id": 4, 23 | "name": "String Cheese", 24 | "category": "Dairy", 25 | "isInCart": false 26 | }, 27 | { 28 | "id": 5, 29 | "name": "Swiss Cheese", 30 | "category": "Dairy", 31 | "isInCart": false 32 | }, 33 | { 34 | "id": 6, 35 | "name": "Cookies", 36 | "category": "Dessert", 37 | "isInCart": false 38 | } 39 | ] 40 | } -------------------------------------------------------------------------------- /.github/workflows/canvas-sync-ruby-update.yml: -------------------------------------------------------------------------------- 1 | name: Sync with Canvas Ruby v2.7 2 | 3 | on: 4 | push: 5 | branches: [master, main] 6 | paths: 7 | - 'README.md' 8 | 9 | jobs: 10 | sync: 11 | name: Sync with Canvas 12 | 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | 18 | - name: Set up Ruby 19 | uses: ruby/setup-ruby@v1 20 | with: 21 | ruby-version: 2.7 22 | 23 | - name: Install github-to-canvas 24 | run: gem install github-to-canvas 25 | 26 | # Secret stored in learn-co-curriculum Settings/Secrets 27 | - name: Sync from .canvas file 28 | run: github-to-canvas -a -lr 29 | env: 30 | CANVAS_API_KEY: ${{ secrets.CANVAS_API_KEY }} 31 | CANVAS_API_PATH: ${{ secrets.CANVAS_API_PATH }} 32 | -------------------------------------------------------------------------------- /src/components/ItemForm.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | 3 | function ItemForm() { 4 | const [name, setName] = useState(""); 5 | const [category, setCategory] = useState("Produce"); 6 | 7 | return ( 8 |
    9 | 18 | 19 | 31 | 32 | 33 |
    34 | ); 35 | } 36 | 37 | export default ItemForm; 38 | -------------------------------------------------------------------------------- /src/components/ShoppingList.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import ItemForm from "./ItemForm"; 3 | import Filter from "./Filter"; 4 | import Item from "./Item"; 5 | 6 | function ShoppingList() { 7 | const [selectedCategory, setSelectedCategory] = useState("All"); 8 | const [items, setItems] = useState([]); 9 | 10 | function handleCategoryChange(category) { 11 | setSelectedCategory(category); 12 | } 13 | 14 | const itemsToDisplay = items.filter((item) => { 15 | if (selectedCategory === "All") return true; 16 | 17 | return item.category === selectedCategory; 18 | }); 19 | 20 | return ( 21 |
    22 | 23 | 27 |
      28 | {itemsToDisplay.map((item) => ( 29 | 30 | ))} 31 |
    32 |
    33 | ); 34 | } 35 | 36 | export default ShoppingList; 37 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-hooks-fetch-crud-code-along", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@learn-co-curriculum/jest-learn-reporter": "^0.1.0", 7 | "@testing-library/jest-dom": "^5.11.4", 8 | "@testing-library/react": "^11.1.0", 9 | "@testing-library/user-event": "^12.1.10", 10 | "json-server": "^0.16.3", 11 | "mocha": "^8.2.1", 12 | "msw": "^0.24.2", 13 | "react": "^17.0.2", 14 | "react-dom": "^17.0.2", 15 | "react-scripts": "^5.0.1", 16 | "whatwg-fetch": "^3.6.2" 17 | }, 18 | "scripts": { 19 | "start": "react-scripts start", 20 | "build": "react-scripts build", 21 | "test": "echo \".results.json\" && react-scripts test --reporters=@learn-co-curriculum/jest-learn-reporter --reporters=default --watchAll", 22 | "eject": "react-scripts eject", 23 | "server": "json-server --watch db.json --port=4000" 24 | }, 25 | "eslintConfig": { 26 | "extends": [ 27 | "react-app", 28 | "react-app/jest" 29 | ] 30 | }, 31 | "browserslist": { 32 | "production": [ 33 | ">0.2%", 34 | "not dead", 35 | "not op_mini all" 36 | ], 37 | "development": [ 38 | "last 1 chrome version", 39 | "last 1 firefox version", 40 | "last 1 safari version" 41 | ] 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/mocks/handlers.js: -------------------------------------------------------------------------------- 1 | import { rest } from "msw"; 2 | import { data } from "./data"; 3 | 4 | let items = [...data]; 5 | let id = items[items.length - 1].id; 6 | 7 | export function resetData() { 8 | items = [...data]; 9 | id = items[items.length - 1].id; 10 | } 11 | 12 | export const handlers = [ 13 | rest.get("http://localhost:4000/items", (req, res, ctx) => { 14 | return res(ctx.json(items)); 15 | }), 16 | rest.post("http://localhost:4000/items", (req, res, ctx) => { 17 | id++; 18 | const item = { id, ...req.body }; 19 | items.push(item); 20 | return res(ctx.json(item)); 21 | }), 22 | rest.delete("http://localhost:4000/items/:id", (req, res, ctx) => { 23 | const { id } = req.params; 24 | if (isNaN(parseInt(id))) { 25 | return res(ctx.status(404), ctx.json({ message: "Invalid ID" })); 26 | } 27 | items = items.filter((q) => q.id !== parseInt(id)); 28 | return res(ctx.json({})); 29 | }), 30 | rest.patch("http://localhost:4000/items/:id", (req, res, ctx) => { 31 | const { id } = req.params; 32 | if (isNaN(parseInt(id))) { 33 | return res(ctx.status(404), ctx.json({ message: "Invalid ID" })); 34 | } 35 | const itemIndex = items.findIndex((item) => item.id === parseInt(id)); 36 | items[itemIndex] = { ...items[itemIndex], ...req.body }; 37 | return res(ctx.json(items[itemIndex])); 38 | }), 39 | ]; 40 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # Learn.co Educational Content License 2 | 3 | Copyright (c) 2021 Flatiron School, Inc 4 | 5 | The Flatiron School, Inc. owns this Educational Content. However, the Flatiron 6 | School supports the development and availability of educational materials in the 7 | public domain. Therefore, the Flatiron School grants Users of the Flatiron 8 | Educational Content set forth in this repository certain rights to reuse, build 9 | upon and share such Educational Content subject to the terms of the Educational 10 | Content License set forth [here](http://learn.co/content-license) 11 | (http://learn.co/content-license). You must read carefully the terms and 12 | conditions contained in the Educational Content License as such terms govern 13 | access to and use of the Educational Content. 14 | 15 | Flatiron School is willing to allow you access to and use of the Educational 16 | Content only on the condition that you accept all of the terms and conditions 17 | contained in the Educational Content License set forth 18 | [here](http://learn.co/content-license) (http://learn.co/content-license). By 19 | accessing and/or using the Educational Content, you are agreeing to all of the 20 | terms and conditions contained in the Educational Content License. If you do not 21 | agree to any or all of the terms of the Educational Content License, you are 22 | prohibited from accessing, reviewing or using in any way the Educational 23 | Content. 24 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Learn.co Curriculum 2 | 3 | We're really exited that you're about to contribute to the 4 | [open curriculum](https://learn.co/content-license) on 5 | [Learn.co](https://learn.co). If this is your first time contributing, please 6 | continue reading to learn how to make the most meaningful and useful impact 7 | possible. 8 | 9 | ## Raising an Issue to Encourage a Contribution 10 | 11 | If you notice a problem with the curriculum that you believe needs improvement 12 | but you're unable to make the change yourself, you should raise a Github issue 13 | containing a clear description of the problem. Include relevant snippets of 14 | the content and/or screenshots if applicable. Curriculum owners regularly review 15 | issue lists and your issue will be prioritized and addressed as appropriate. 16 | 17 | ## Submitting a Pull Request to Suggest an Improvement 18 | 19 | If you see an opportunity for improvement and can make the change yourself go 20 | ahead and use a typical git workflow to make it happen: 21 | 22 | - Fork this curriculum repository 23 | - Make the change on your fork, with descriptive commits in the standard format 24 | - Open a Pull Request against this repo 25 | 26 | A curriculum owner will review your change and approve or comment on it in due 27 | course. 28 | 29 | ## Why Contribute? 30 | 31 | Curriculum on Learn is publicly and freely available under Learn's 32 | [Educational Content License](https://learn.co/content-license). By 33 | embracing an open-source contribution model, our goal is for the curriculum 34 | on Learn to become, in time, the best educational content the world has 35 | ever seen. 36 | 37 | We need help from the community of Learners to maintain and improve the 38 | educational content. Everything from fixing typos, to correcting 39 | out-dated information, to improving exposition, to adding better examples, 40 | to fixing tests—all contributions to making the curriculum more effective are 41 | welcome. 42 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --magenta: rgb(210, 51, 210); 3 | --cyan: rgb(98, 230, 230); 4 | --yellow: rgb(237, 237, 32); 5 | --font-color: rgb(33, 33, 33); 6 | --font-color-light: rgb(155, 155, 155); 7 | --background: rgb(255, 255, 255); 8 | --background-light: rgb(230, 230, 230); 9 | } 10 | 11 | * { 12 | margin: 0; 13 | padding: 0; 14 | box-sizing: border-box; 15 | } 16 | 17 | body { 18 | background: rgb(231, 231, 231); 19 | font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; 20 | font-size: 18px; 21 | } 22 | 23 | button, 24 | select, 25 | input { 26 | border: none; 27 | padding: 0.5rem; 28 | border-radius: 4px; 29 | font-size: 1rem; 30 | background: var(--background-light); 31 | } 32 | 33 | span[role="image"] { 34 | color: transparent; 35 | text-shadow: 0 0 0 black; 36 | } 37 | 38 | .App.dark { 39 | --font-color: rgb(255, 255, 255); 40 | --background: rgb(50, 50, 50); 41 | --background-light: rgb(101, 101, 101); 42 | transition: all 0.3s; 43 | } 44 | 45 | .App { 46 | margin: 2rem auto; 47 | padding: 1rem 2rem; 48 | max-width: 700px; 49 | background: var(--background); 50 | color: var(--font-color); 51 | box-shadow: 4px 4px 10px rgba(33, 33, 33, 0.2); 52 | border-radius: 10px; 53 | } 54 | 55 | header { 56 | display: flex; 57 | justify-content: space-between; 58 | align-items: center; 59 | border-top-left-radius: 10px; 60 | border-top-right-radius: 10px; 61 | } 62 | 63 | header h2 { 64 | padding: 1rem 0; 65 | } 66 | 67 | header button { 68 | background: var(--yellow); 69 | } 70 | 71 | .Filter { 72 | display: flex; 73 | justify-content: space-between; 74 | } 75 | 76 | .Filter select, 77 | .Filter input { 78 | width: 45%; 79 | margin: 1rem 0; 80 | background: var(--cyan); 81 | } 82 | 83 | .Items { 84 | list-style: none; 85 | } 86 | 87 | .Items li { 88 | margin: 0.5rem 0; 89 | padding: 0.25rem; 90 | display: grid; 91 | grid-template-columns: 1fr 1fr 160px 100px; 92 | column-gap: 1rem; 93 | align-items: center; 94 | border-radius: 4px; 95 | } 96 | 97 | .Items li.in-cart { 98 | background: var(--background-light); 99 | } 100 | 101 | .Items li.in-cart span { 102 | text-decoration: line-through; 103 | } 104 | 105 | .Items button.add { 106 | background-color: var(--yellow); 107 | } 108 | 109 | .Items button.remove { 110 | background-color: var(--magenta); 111 | color: white; 112 | } 113 | 114 | .Items li .category { 115 | justify-self: end; 116 | color: var(--font-color-light); 117 | font-size: 0.8em; 118 | } 119 | 120 | .NewItem { 121 | border-bottom: 2px solid var(--background-light); 122 | border-top: 2px solid var(--background-light); 123 | padding: 1rem 0; 124 | display: flex; 125 | justify-content: space-between; 126 | align-items: flex-end; 127 | gap: 1rem; 128 | } 129 | 130 | .NewItem button { 131 | background: var(--yellow); 132 | } 133 | 134 | .NewItem label { 135 | font-size: 0.8em; 136 | display: flex; 137 | flex-direction: column; 138 | flex: 1; 139 | } 140 | -------------------------------------------------------------------------------- /src/__tests__/ShoppingList.test.js: -------------------------------------------------------------------------------- 1 | import "whatwg-fetch"; 2 | import "@testing-library/jest-dom"; 3 | import { 4 | render, 5 | screen, 6 | fireEvent, 7 | waitForElementToBeRemoved, 8 | } from "@testing-library/react"; 9 | import { resetData } from "../mocks/handlers"; 10 | import { server } from "../mocks/server"; 11 | import ShoppingList from "../components/ShoppingList"; 12 | 13 | beforeAll(() => server.listen()); 14 | afterEach(() => { 15 | server.resetHandlers(); 16 | resetData(); 17 | }); 18 | afterAll(() => server.close()); 19 | 20 | test("displays all the items from the server after the initial render", async () => { 21 | render(); 22 | 23 | const yogurt = await screen.findByText(/Yogurt/); 24 | expect(yogurt).toBeInTheDocument(); 25 | 26 | const pomegranate = await screen.findByText(/Pomegranate/); 27 | expect(pomegranate).toBeInTheDocument(); 28 | 29 | const lettuce = await screen.findByText(/Lettuce/); 30 | expect(lettuce).toBeInTheDocument(); 31 | }); 32 | 33 | test("adds a new item to the list when the ItemForm is submitted", async () => { 34 | const { rerender } = render(); 35 | 36 | const dessertCount = screen.queryAllByText(/Dessert/).length; 37 | 38 | fireEvent.change(screen.queryByLabelText(/Name/), { 39 | target: { value: "Ice Cream" }, 40 | }); 41 | 42 | fireEvent.change(screen.queryByLabelText(/Category/), { 43 | target: { value: "Dessert" }, 44 | }); 45 | 46 | fireEvent.submit(screen.queryByText(/Add to List/)); 47 | 48 | const iceCream = await screen.findByText(/Ice Cream/); 49 | expect(iceCream).toBeInTheDocument(); 50 | 51 | const desserts = await screen.findAllByText(/Dessert/); 52 | expect(desserts.length).toBe(dessertCount + 1); 53 | 54 | // Rerender the component to ensure the item was persisted 55 | rerender(); 56 | 57 | const rerenderedIceCream = await screen.findByText(/Ice Cream/); 58 | expect(rerenderedIceCream).toBeInTheDocument(); 59 | }); 60 | 61 | test("updates the isInCart status of an item when the Add/Remove from Cart button is clicked", async () => { 62 | const { rerender } = render(); 63 | 64 | const addButtons = await screen.findAllByText(/Add to Cart/); 65 | 66 | expect(addButtons.length).toBe(3); 67 | expect(screen.queryByText(/Remove From Cart/)).not.toBeInTheDocument(); 68 | 69 | fireEvent.click(addButtons[0]); 70 | 71 | const removeButton = await screen.findByText(/Remove From Cart/); 72 | expect(removeButton).toBeInTheDocument(); 73 | 74 | // Rerender the component to ensure the item was persisted 75 | rerender(); 76 | 77 | const rerenderedAddButtons = await screen.findAllByText(/Add to Cart/); 78 | const rerenderedRemoveButtons = await screen.findAllByText( 79 | /Remove From Cart/ 80 | ); 81 | 82 | expect(rerenderedAddButtons.length).toBe(2); 83 | expect(rerenderedRemoveButtons.length).toBe(1); 84 | }); 85 | 86 | test("removes an item from the list when the delete button is clicked", async () => { 87 | const { rerender } = render(); 88 | 89 | const yogurt = await screen.findByText(/Yogurt/); 90 | expect(yogurt).toBeInTheDocument(); 91 | 92 | const deleteButtons = await screen.findAllByText(/Delete/); 93 | fireEvent.click(deleteButtons[0]); 94 | 95 | await waitForElementToBeRemoved(() => screen.queryByText(/Yogurt/)); 96 | 97 | // Rerender the component to ensure the item was persisted 98 | rerender(); 99 | 100 | const rerenderedDeleteButtons = await screen.findAllByText(/Delete/); 101 | 102 | expect(rerenderedDeleteButtons.length).toBe(2); 103 | expect(screen.queryByText(/Yogurt/)).not.toBeInTheDocument(); 104 | }); 105 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Fetch CRUD Codealong 2 | 3 | ## Learning Goals 4 | 5 | - Write `fetch` requests for `GET`, `POST`, `PATCH`, and `DELETE` 6 | - Initiate `fetch` requests with the `useEffect` hook 7 | - Initiate `fetch` requests from user events 8 | - Update state and trigger a re-render after receiving a response to the 9 | `fetch` request 10 | - Perform CRUD actions on arrays in state 11 | 12 | ## Introduction 13 | 14 | Up to this point, we've seen how to use `fetch` in a React application for some 15 | common single-page application patterns, such as: 16 | 17 | - Requesting data from a server when our application first loads 18 | - Persisting data to a server when a user submits a form 19 | 20 | In both of those cases, our workflow in React follows a similar pattern: 21 | 22 | - When X event occurs (_our application loads_, _a user submits a form_) 23 | - Make Y fetch request (_GET_, _POST_) 24 | - Update Z state (_add all items to state_, _add a single item to state_) 25 | 26 | In this codealong lesson, we'll get more practice following this pattern to 27 | build out all four CRUD actions to work with both our **server-side** data (the 28 | database; in our case, the `db.json` file) as well as our **client-side** data 29 | (our React state). We'll be revisiting the shopping list application from the 30 | previous module, this time using `json-server` to create a RESTful API which we 31 | can interact with from React. 32 | 33 | ## Instructions 34 | 35 | To get started, let's install our dependencies: 36 | 37 | ```console 38 | $ npm install 39 | ``` 40 | 41 | Then, to run `json-server`, we'll be using the `server` script in the 42 | `package.json` file: 43 | 44 | ```console 45 | $ npm run server 46 | ``` 47 | 48 | This will run `json-server` on [http://localhost:4000](http://localhost:4000). 49 | Before moving ahead, open 50 | [http://localhost:4000/items](http://localhost:4000/items) in the browser and 51 | familiarize yourself with the data. What are the important keys on each object? 52 | 53 | Leave `json-server` running. Open a new terminal, and run React with: 54 | 55 | ```console 56 | $ npm start 57 | ``` 58 | 59 | View the application in the browser at 60 | [http://localhost:3000](http://localhost:3000). We don't have any data to 61 | display yet, but eventually, we'll want to display the list of items from 62 | `json-server` in our application and be able to perform CRUD actions on them. 63 | 64 | Take some time now to familiarize yourself with the components in the 65 | `src/components` folder. Which components are stateful and why? What does our 66 | component hierarchy look like? 67 | 68 | Once you've familiarized yourself with the starter code, let's start building 69 | out some features! 70 | 71 | ### Displaying Items 72 | 73 | Our first goal will be to display a list of items from the server when the 74 | application first loads. Let's see how this goal fits into this common pattern 75 | for working with server-side data in React: 76 | 77 | - When X event occurs (_our application loads_) 78 | - Make Y fetch request (_GET /items_) 79 | - Update Z state (_add all items to state_) 80 | 81 | With that structure in mind, our first step is to **identify which component 82 | triggers this event**. In this case, the event isn't triggered by a user 83 | interacting with a specific DOM element. We want to initiate the fetch request 84 | without making our users click a button or anything like that. 85 | 86 | So the event we're looking for is a **side-effect** of a component being 87 | rendered. Which component? Well, we can make that determination by looking at 88 | which state we're trying to update. In our case, it's the `items` state which is 89 | held in the `ShoppingList` component. 90 | 91 | We can call the `useEffect` hook in the `ShoppingList` component to initiate our 92 | `fetch` request. Let's start by using `console.log` to ensure that our syntax is 93 | correct, and that we're fetching data from the server: 94 | 95 | ```jsx 96 | // src/components/ShoppingList.js 97 | 98 | // import useEffect 99 | import React, { useEffect, useState } from "react"; 100 | // ...rest of imports 101 | 102 | function ShoppingList() { 103 | const [selectedCategory, setSelectedCategory] = useState("All"); 104 | const [items, setItems] = useState([]); 105 | 106 | // Add useEffect hook 107 | useEffect(() => { 108 | fetch("http://localhost:4000/items") 109 | .then((r) => r.json()) 110 | .then((items) => console.log(items)); 111 | }, []); 112 | 113 | // ...rest of component 114 | } 115 | ``` 116 | 117 | Check your console in the browser — you should see an **array of objects** 118 | representing each item in our shopping list. 119 | 120 | Now all that's left to do is to update state, so that React will re-render our 121 | components and use the new data to display our shopping list. Our goal here is 122 | to replace our current `items` state, which is an empty array, with the new 123 | array from the server: 124 | 125 | ```jsx 126 | // src/components/ShoppingList.js 127 | 128 | function ShoppingList() { 129 | const [selectedCategory, setSelectedCategory] = useState("All"); 130 | const [items, setItems] = useState([]); 131 | 132 | // Update state by passing the array of items to setItems 133 | useEffect(() => { 134 | fetch("http://localhost:4000/items") 135 | .then((r) => r.json()) 136 | .then((items) => setItems(items)); 137 | }, []); 138 | 139 | // ...rest of component 140 | } 141 | ``` 142 | 143 | Check your work in the browser and make sure you see the list of items. Which 144 | component is responsible for rendering each item from the list of items in 145 | state? 146 | 147 | To recap: 148 | 149 | - When X event occurs 150 | - Use the `useEffect` hook to trigger a side-effect in the `ShoppingList` 151 | component after the component first renders 152 | - Make Y fetch request 153 | - Make a `GET` request to `/items` to retrieve a list of items 154 | - Update Z state 155 | - Replace our current list of items with the new list 156 | 157 | ### Creating Items 158 | 159 | Our next goal will be to add a new item to our database on the server when a 160 | user submits the form. Once again, let's plan out our steps: 161 | 162 | - When X event occurs (_a user submits the form_) 163 | - Make Y fetch request (_POST /items with the new item data_) 164 | - Update Z state (_add a new item to state_) 165 | 166 | To tackle the first step, we'll need to **identify which component triggers the 167 | event**. In this case, the form in question is in the `ItemForm` component. 168 | Let's start by handling the form `submit` event in this component and access 169 | the data from the form inputs, which are saved in state: 170 | 171 | ```jsx 172 | // src/components/ItemForm.js 173 | 174 | function ItemForm() { 175 | const [name, setName] = useState(""); 176 | const [category, setCategory] = useState("Produce"); 177 | 178 | // Add function to handle submissions 179 | function handleSubmit(e) { 180 | e.preventDefault(); 181 | console.log("name:", name); 182 | console.log("category:", category); 183 | } 184 | 185 | return ( 186 | // Set up the form to call handleSubmit when the form is submitted 187 |
    188 | {/** ...form inputs here */} 189 |
    190 | ); 191 | } 192 | ``` 193 | 194 | One step down, two to go! Next, we need to determine what data needs to be sent 195 | to the server with our `fetch` request. Our goal is to create a new item, and it 196 | should have the same structure as other items on the server. So we'll need to 197 | send an object that looks like this: 198 | 199 | ```json 200 | { 201 | "name": "Yogurt", 202 | "category": "Dairy", 203 | "isInCart": false 204 | } 205 | ``` 206 | 207 | Let's create this item in our `handleSubmit` function using the data from the 208 | form state: 209 | 210 | ```js 211 | // src/components/ItemForm.js 212 | 213 | function handleSubmit(e) { 214 | e.preventDefault(); 215 | const itemData = { 216 | name: name, 217 | category: category, 218 | isInCart: false, 219 | }; 220 | console.log(itemData); 221 | } 222 | ``` 223 | 224 | Check your work in the browser again and make sure you are able to log an item 225 | to the console that has the right key/value pairs. Now, on to the `fetch`! 226 | 227 | ```js 228 | function handleSubmit(e) { 229 | e.preventDefault(); 230 | const itemData = { 231 | name: name, 232 | category: category, 233 | isInCart: false, 234 | }; 235 | fetch("http://localhost:4000/items", { 236 | method: "POST", 237 | headers: { 238 | "Content-Type": "application/json", 239 | }, 240 | body: JSON.stringify(itemData), 241 | }) 242 | .then((r) => r.json()) 243 | .then((newItem) => console.log(newItem)); 244 | } 245 | ``` 246 | 247 | Recall that to make a `POST` request, we must provide additional options along 248 | with the URL when calling `fetch`: the `method` (HTTP verb), the `headers` 249 | (specifying that we are sending a JSON string in the request), and the `body` 250 | (the stringified object we are sending). If you need a refresher on this syntax, 251 | check out the [MDN article on Using Fetch][using fetch]. 252 | 253 | Try submitting the form once more. You should now see a new item logged to the 254 | console that includes an `id` attribute from the server. You can also verify the 255 | object was persisted by refreshing the page in the browser and seeing the new 256 | item at the bottom of the shopping list. 257 | 258 | However, our goal isn't to make our users refresh the page to see their newly 259 | created item — we want it to show up as soon as it's been persisted. So we have 260 | one more step left: **updating state**. 261 | 262 | For this final step, we need to consider: 263 | 264 | - Which component owns the state that we're trying to update? 265 | - How can we get the data from the `ItemForm` component to the component that 266 | owns state? 267 | - How do we correctly update state? 268 | 269 | For the first question, we're trying to update state in the `ShoppingList` 270 | component. Our goal is to display the new item in the list alongside the other 271 | items, and this is the component that is responsible for that part of our 272 | application. Since the `ShoppingList` component is a **parent** component to the 273 | `ItemForm` component, we'll need to **pass a callback function as a prop** so 274 | that the `ItemForm` component can send the new item up to the `ShoppingList`. 275 | 276 | Let's add a `handleAddItem` function to `ShoppingList`, and pass a reference to 277 | that function as a prop called `onAddItem` to the `ItemForm`: 278 | 279 | ```jsx 280 | // src/components/ShoppingList.js 281 | 282 | function ShoppingList() { 283 | const [selectedCategory, setSelectedCategory] = useState("All"); 284 | const [items, setItems] = useState([]); 285 | 286 | useEffect(() => { 287 | fetch("http://localhost:4000/items") 288 | .then((r) => r.json()) 289 | .then((items) => setItems(items)); 290 | }, []); 291 | 292 | // add this function! 293 | function handleAddItem(newItem) { 294 | console.log("In ShoppingList:", newItem); 295 | } 296 | 297 | function handleCategoryChange(category) { 298 | setSelectedCategory(category); 299 | } 300 | 301 | const itemsToDisplay = items.filter((item) => { 302 | if (selectedCategory === "All") return true; 303 | 304 | return item.category === selectedCategory; 305 | }); 306 | 307 | return ( 308 |
    309 | {/* add the onAddItem prop! */} 310 | 311 | 315 |
      316 | {itemsToDisplay.map((item) => ( 317 | 318 | ))} 319 |
    320 |
    321 | ); 322 | } 323 | ``` 324 | 325 | Then, we can use this prop in the `ItemForm` to send the new item **up** to the 326 | `ShoppingList` when we receive a response from the server: 327 | 328 | ```jsx 329 | // src/components/ItemForm.js 330 | 331 | // destructure the onAddItem prop 332 | function ItemForm({ onAddItem }) { 333 | const [name, setName] = useState(""); 334 | const [category, setCategory] = useState("Produce"); 335 | 336 | function handleSubmit(e) { 337 | e.preventDefault(); 338 | const itemData = { 339 | name: name, 340 | category: category, 341 | isInCart: false, 342 | }; 343 | fetch("http://localhost:4000/items", { 344 | method: "POST", 345 | headers: { 346 | "Content-Type": "application/json", 347 | }, 348 | body: JSON.stringify(itemData), 349 | }) 350 | .then((r) => r.json()) 351 | // call the onAddItem prop with the newItem 352 | .then((newItem) => onAddItem(newItem)); 353 | } 354 | 355 | // ...rest of component 356 | } 357 | ``` 358 | 359 | Check your work by submitting the form once more. You should now see the new 360 | item logged to the console, this time from the `ShoppingList` component. We're 361 | getting close! For the last step, we need to call `setState` with a new array 362 | that has our new item at the end. Recall from our lessons on working with arrays 363 | in state that we can use the spread operator to perform this action: 364 | 365 | ```js 366 | // src/components/ShoppingList.js 367 | 368 | function handleAddItem(newItem) { 369 | setItems([...items, newItem]); 370 | } 371 | ``` 372 | 373 | Now each time a user submits the form, a new item will be added to our database 374 | and will also be added to our client-side state, so that the user will 375 | immediately see their item in the list. 376 | 377 | Let's recap our steps here: 378 | 379 | - When X event occurs 380 | - When a user submits the `ItemForm`, handle the form submit event and access 381 | data from the form using state 382 | - Make Y fetch request 383 | - Make a `POST` request to `/items`, passing the form data in the **body** of 384 | the request, and access the newly created item in the response 385 | - Update Z state 386 | - Send the item from the fetch response to the `ShoppingList` component, and 387 | set state by creating a new array with our current items from state, plus 388 | the new item at the end 389 | 390 | **Phew!** This is a good time to **take a break** before proceeding — we've got 391 | a few more steps to cover. Once you're ready and recharged, we'll dive back in. 392 | 393 | ### Updating Items 394 | 395 | For our update action, we'd like to give our users the ability to keep track of which 396 | items from their shopping list they've added to their cart. Once more, we can outline 397 | the basic steps for this action like so: 398 | 399 | - When X event occurs (_a user clicks the Add to Cart button_) 400 | - Make Y fetch request (_PATCH /items_) 401 | - Update Z state (_update the `isInCart` status for the item_) 402 | 403 | From here, we'll again need to **identify which component triggers the event**. 404 | Can you find where the "Add to Cart" button lives in our code? Yep! It's in the 405 | `Item` component. We'll start by adding an event handler for clicks on the 406 | button: 407 | 408 | ```jsx 409 | // src/components/Item.js 410 | 411 | function Item({ item }) { 412 | // Add function to handle button click 413 | function handleAddToCartClick() { 414 | console.log("clicked item:", item); 415 | } 416 | 417 | return ( 418 |
  • 419 | {item.name} 420 | {item.category} 421 | {/* add the onClick listener */} 422 | 428 | 429 |
  • 430 | ); 431 | } 432 | ``` 433 | 434 | Check your work by clicking this button for different items — you should see 435 | each item logged to the console. We can access the `item` variable in the 436 | `handleAddToCartClick` function thanks to JavaScript's scope rules. 437 | 438 | Next, let's write out our `PATCH` request: 439 | 440 | ```js 441 | // src/components/Item.js 442 | 443 | function handleAddToCartClick() { 444 | // add fetch request 445 | fetch(`http://localhost:4000/items/${item.id}`, { 446 | method: "PATCH", 447 | headers: { 448 | "Content-Type": "application/json", 449 | }, 450 | body: JSON.stringify({ 451 | isInCart: !item.isInCart, 452 | }), 453 | }) 454 | .then((r) => r.json()) 455 | .then((updatedItem) => console.log(updatedItem)); 456 | } 457 | ``` 458 | 459 | Just like with our `POST` request, we need to specify the `method`, `headers`, 460 | and `body` options for our `PATCH` request as well. We also need to include the 461 | item's ID in the URL so that our server knows which item we're trying to update. 462 | 463 | Since our goal is to let users add or remove items from their cart, we need to 464 | toggle the `isInCart` property of the item on the server (and eventually 465 | client-side as well). So in the body of the request, we send an object with the 466 | key we are updating, along with the new value. 467 | 468 | Check your work by clicking the "Add to Cart" button and see if you receive an 469 | updated item in the response from the server. Then, refresh the page to verify 470 | that the change was persisted server-side. 471 | 472 | Two steps done! Once more, our last step is to update state. Let's think this 473 | through. Right now, what state determines whether or not the item is in our 474 | cart? Where does that state live? Do we need to add new state? 475 | 476 | A reasonable thought here is that we might need to add new state to the `Item` 477 | component to keep track of whether or not the item is in the cart. While we 478 | could theoretically make this approach work, it would be an anti-pattern: we'd 479 | be **duplicating state**, which makes our components harder to work with and 480 | more prone to bugs. 481 | 482 | We already have state in our `ShoppingList` component that tells us which items 483 | are in the cart. So instead of creating new state, our goal is to call 484 | `setItems` in the `ShoppingList` component with a new list of items, where the 485 | `isInCart` state of our updated item matches its state on the server. 486 | 487 | Just like with our `ItemForm` deliverable, let's start by creating a callback 488 | function in the `ShoppingList` component and passing it as a prop to the `Item` 489 | component: 490 | 491 | ```jsx 492 | // src/components/ShoppingList.js 493 | 494 | function ShoppingList() { 495 | const [selectedCategory, setSelectedCategory] = useState("All"); 496 | const [items, setItems] = useState([]); 497 | 498 | useEffect(() => { 499 | fetch("http://localhost:4000/items") 500 | .then((r) => r.json()) 501 | .then((items) => setItems(items)); 502 | }, []); 503 | 504 | // add this callback function 505 | function handleUpdateItem(updatedItem) { 506 | console.log("In ShoppingCart:", updatedItem); 507 | } 508 | 509 | // ...rest of component 510 | 511 | return ( 512 |
    513 | 514 | 518 |
      519 | {/* pass it as a prop to Item */} 520 | {itemsToDisplay.map((item) => ( 521 | 522 | ))} 523 |
    524 |
    525 | ); 526 | } 527 | ``` 528 | 529 | In the `Item` component, we can destructure the `onUpdateItem` prop and call it 530 | when we have the updated item response from the server: 531 | 532 | ```jsx 533 | // src/components/Item.js 534 | 535 | // Destructure the onUpdateItem prop 536 | function Item({ item, onUpdateItem }) { 537 | function handleAddToCartClick() { 538 | // Call onUpdateItem, passing the data returned from the fetch request 539 | fetch(`http://localhost:4000/items/${item.id}`, { 540 | method: "PATCH", 541 | headers: { 542 | "Content-Type": "application/json", 543 | }, 544 | body: JSON.stringify({ 545 | isInCart: !item.isInCart, 546 | }), 547 | }) 548 | .then((r) => r.json()) 549 | .then((updatedItem) => onUpdateItem(updatedItem)); 550 | } 551 | // ... rest of component 552 | } 553 | ``` 554 | 555 | Check your work by clicking the button once more. You should now see the updated 556 | item logged to the console, this time from the `ShoppingList` component. 557 | 558 | As a last step, we need to call `setState` with a new array that replaces one 559 | item with the new updated item from the server. Recall from our lessons on 560 | working with arrays in state that we can use `.map` to help create this new 561 | array: 562 | 563 | ```js 564 | // src/components/ShoppingList.js 565 | 566 | function handleUpdateItem(updatedItem) { 567 | const updatedItems = items.map((item) => { 568 | if (item.id === updatedItem.id) { 569 | return updatedItem; 570 | } else { 571 | return item; 572 | } 573 | }); 574 | setItems(updatedItems); 575 | } 576 | ``` 577 | 578 | Clicking the button should now toggle the `isInCart` property of any item in the 579 | list on the server as well as in our React state! To recap: 580 | 581 | - When X event occurs 582 | - When a user clicks the Add to Cart button, handle the button click 583 | - Make Y fetch request 584 | - Make a `PATCH` request to `/items/:id`, using the clicked item's data for 585 | the ID and body of the request, and access the updated item in the response 586 | - Update Z state 587 | - Send the item from the fetch response to the `ShoppingList` component, and 588 | set state by creating a new array which contains the updated item in place 589 | of the old item 590 | 591 | ### Deleting Items 592 | 593 | Last one! For our delete action, we'd like to give our users the ability to 594 | remove items from their shopping list: 595 | 596 | - When X event occurs (_a user clicks the DELETE button_) 597 | - Make Y fetch request (_DELETE /items_) 598 | - Update Z state (_remove the item from the list_) 599 | 600 | From here, we'll again need to **identify which component triggers the event**. 601 | Our delete button is in the `Item` component, so we'll start by adding an event 602 | handler for clicks on the button: 603 | 604 | ```jsx 605 | // src/components/Item.js 606 | 607 | function Item({ item, onUpdateItem }) { 608 | // ...rest of component 609 | 610 | function handleDeleteClick() { 611 | console.log(item); 612 | } 613 | 614 | return ( 615 |
  • 616 | {/* ... rest of JSX */} 617 | 618 | {/* ... add onClick */} 619 | 622 |
  • 623 | ); 624 | } 625 | ``` 626 | 627 | This step should feel similar to our approach for the update action. Next, let's 628 | write out our `DELETE` request: 629 | 630 | ```js 631 | // src/components/Item.js 632 | 633 | function handleDeleteClick() { 634 | fetch(`http://localhost:4000/items/${item.id}`, { 635 | method: "DELETE", 636 | }) 637 | .then((r) => r.json()) 638 | .then(() => console.log("deleted!")); 639 | } 640 | ``` 641 | 642 | Note that for a `DELETE` request, we must include the ID of the item we're 643 | deleting in the URL. We only need the `method` option — no `body` or `headers` 644 | are needed since we don't have any additional data to send besides the ID. 645 | 646 | You can verify that the item was successfully deleted by clicking the button, 647 | checking that the console message of `"deleted!"` appears, and refreshing the 648 | page to check that the item is no longer on the list. 649 | 650 | Our last step is to update state. Once again, the state that determines which 651 | items are being displayed is the `items` state in the `ShoppingList` component, 652 | so we need to call `setItems` in that component with a new list of items that 653 | **does not contain our deleted item**. 654 | 655 | We'll pass a callback down from `ShoppingList` to `Item`, just like we did for 656 | the update action: 657 | 658 | ```jsx 659 | // src/components/ShoppingList.js 660 | 661 | function ShoppingList() { 662 | const [selectedCategory, setSelectedCategory] = useState("All"); 663 | const [items, setItems] = useState([]); 664 | 665 | useEffect(() => { 666 | fetch("http://localhost:4000/items") 667 | .then((r) => r.json()) 668 | .then((items) => setItems(items)); 669 | }, []); 670 | 671 | // add this callback function 672 | function handleDeleteItem(deletedItem) { 673 | console.log("In ShoppingCart:", deletedItem); 674 | } 675 | 676 | // ...rest of component 677 | 678 | return ( 679 |
    680 | 681 | 685 |
      686 | {/* pass it as a prop to Item */} 687 | {itemsToDisplay.map((item) => ( 688 | 694 | ))} 695 |
    696 |
    697 | ); 698 | } 699 | ``` 700 | 701 | Call the `onDeleteItem` prop in the `Item` component once the item has been 702 | deleted from the server, and pass up the item that was clicked: 703 | 704 | ```jsx 705 | // src/components/Item.js 706 | 707 | // Deconstruct the onDeleteItem prop 708 | function Item({ item, onUpdateItem, onDeleteItem }) { 709 | function handleDeleteClick() { 710 | // Call onDeleteItem, passing the deleted item 711 | fetch(`http://localhost:4000/items/${item.id}`, { 712 | method: "DELETE", 713 | }) 714 | .then((r) => r.json()) 715 | .then(() => onDeleteItem(item)); 716 | } 717 | // ... rest of component 718 | } 719 | ``` 720 | 721 | As a last step, we need to call `setState` with a new array that removes the 722 | deleted item from the list. Recall from our lessons on working with arrays in 723 | state that we can use `.filter` to help create this new array: 724 | 725 | ```js 726 | // src/components/ShoppingList.js 727 | function handleDeleteItem(deletedItem) { 728 | const updatedItems = items.filter((item) => item.id !== deletedItem.id); 729 | setItems(updatedItems); 730 | } 731 | ``` 732 | 733 | Clicking the delete button should now delete the item in the list on the server 734 | as well as in our React state! To recap: 735 | 736 | - When X event occurs 737 | - When a user clicks the Delete button, handle the button click 738 | - Make Y fetch request 739 | - Make a `DELETE` request to `/items/:id`, using the clicked item's data for 740 | the ID 741 | - Update Z state 742 | - Send the clicked item to the `ShoppingList` component, and set state by 743 | creating a new array in which the deleted item has been filtered out 744 | 745 | ## Conclusion 746 | 747 | Synchronizing state between a client-side application and a server-side 748 | application is a challenging problem! Thankfully, the general steps to 749 | accomplish this in React are the same regardless of what kind of action we are 750 | performing: 751 | 752 | - When X event occurs 753 | - Make Y fetch request 754 | - Update Z state 755 | 756 | Keep these steps in mind any time you're working on a feature involving 757 | synchronizing client and server state. Once you have this general framework, you 758 | can apply the other things you've learned about React to each step, like 759 | handling events, updating state, and passing data between components. 760 | 761 | ## Resources 762 | 763 | - [MDN: Using Fetch][using fetch] 764 | 765 | [using fetch]: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch#uploading_json_data 766 | --------------------------------------------------------------------------------