Learning From Youtube Channel: Net Ninja
Video: MERN Stack Tutorial
Thank you.
#1 – What is the MERN Stack?
Learn how to create a web app using the MERN stack (MongoDB, Express, React & Node.js).
MERN Stack
- Mongo、Express、React、Node.js
- 講解 Front-end (browser) 和 Back-end (server) 關係
- 介紹製作的專案,這個課程不介紹 Auth 功能
Before You Start
- Node.js、MongoDB、React 相關知識
安裝 node.js
- Node.js – 安裝長期穩定版本
- 查詢 Node.js 版本 – node -v
課程檔案資源
#2 – Express App Setup
In this MERN tutorial we’ll set up our initial Express app to power the backend api.
MERN Stack
- 建立 mern stack 專案資料夾
- 在 mern stack 資料夾裡面建立 backend 資料夾
也可以命名為 server 資料夾 - 在 backend 資料夾裡面建立 server.js 檔案
- 建立 package.json 檔案
使用終端機移動到 backend 資料夾 – cd backend
npm init -y 快速建立 package.json 檔案 - 安裝 express 套件 – npm install express
- 修改 server.js 檔案
- 使用終端機執行 server – node server.js
- 因為需要不斷重新執行 server 所以需要使用 nodemon 套件幫助網頁開發
安裝 nodemon 套件 – npm install -g nodemon - 執行 server – nodemon server.js
可避免需要一直不斷重新啟動伺服器的動作 - 修改 package.json 檔案 – 新增 scripts 指令
- 執行 npm run dev 運作 server
- 修改 server.js 檔案,新增 routes
- 建立 .env 檔案,儲存環境變數
有需要上傳到 Github 可建立 .gitignore 檔案忽略文件 - 安裝 dotenv 套件 – npm install dotenv
- 修改 server.js 檔案,使用 dotenv
- 執行指令 npm run dev
- 介紹 API 測試工具 – Postman
http://localhost:4000/,GET 方法、建立 MERN app Collection - 修改 server.js 檔案,middleware
// backend/server.js
require('dotenv').config()
const express = require('express')
// express app
const app = express()
// middleware
app.use((req, res, next) => {
console.log(req.path, req.method)
next()
})
// routes
app.get('/', (req, res) => {
res.json({mssg: 'Welcome to the app'})
})
// listen for requrests
app.listen(process.env.PORT, () => {
console.log('listening on port', process.env.PORT);
})
// package.json
{
"name": "backend",
"version": "1.0.0",
"description": "",
"main": "server.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "node server.js",
"dev": "nodemon server.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"dotenv": "^16.4.5",
"express": "^4.19.2"
}
}
// .env
PORT=4000
#3 – Express Router & API Routes
In this MERN tutorial we’ll create all of the workout routes we need for the api and test them out using POSTMAN.
API Endpoints
GET | /workouts | Gets all the workout documents |
POST | /workouts | Creates a new workout document |
GET | /workouts/:id | Gets a single workout document |
DELETE | /workouts/:id | Deletes a single workout |
PATCH | /workouts/:id | Updates a single workout |
- 在 backend 資料夾裡面建立 routes 資料夾
- 在 routes 資料夾裡面建立 workouts.js 檔案
- 修改 workouts.js 檔案
- 修改 server.js 檔案,workoutRoutes
- 使用 API 測試工具 – POSTMAN
也可以使用其他 API 測試工具
// backend/routes/workouts.js
const express = require('express')
const router = express.Router()
// GET all workouts
router.get('/', (req, res) => {
res.json({mssg: 'GET all workouts'})
})
// GET a single workout
router.get('/:id', (req, res) => {
res.json({mssg: 'GET a single workout'})
})
// POST a new workout
router.post('/', (req, res) => {
res.json({mssg: 'POST a new workout'})
})
// DELETE a workout
router.delete('/:id', (req, res) => {
res.json({mssg: 'DELETE a workout'})
})
// UPDATE a workout
router.patch('/:id', (req, res) => {
res.json({mssg: 'UPDATE a workout'})
})
module.exports = router
// server.js
require('dotenv').config()
const express = require('express')
const workoutRoutes = require('./routes/workouts')
// express app
const app = express()
// middleware
app.use(express.json())
app.use((req, res, next) => {
console.log(req.path, req.method)
next()
})
// routes
app.use('/api/workouts', workoutRoutes)
// listen for requests
app.listen(process.env.PORT, () => {
console.log('listening on port', process.env.PORT)
})
#4 – MongoDB Altas & Mongoose
In this MERN tutorial we’ll set up a database using MongoDB Atlas, and then connect to it from our application using a package called Mongoose.
- MongoDB Atlas
- 建立 Shared Cluster
- Free Shared
- Cloud Provider & Region
aws、地區可自行選擇 - Cluster Name: MERNapp
- SECURITY > Database Access
Username and Password - SECURITY > Network Access
- DEPLOYMENT > Database > Connect
Connect to your application
Add your connection string into your application code 複製 - 修改 .env 檔案
- 安裝 mongoose 套件 – npm install mongoose
- 修改 server.js 檔案
- 執行 npm run dev
// .env
PORT=4000
MONGO_URI="connection string"
// server.js
require('dotenv').config()
const express = require('express')
const mongoose = require('mongoose')
const workoutRoutes = require('./routes/workouts')
// express app
const app = express()
// middleware
app.use(express.json())
app.use((req, res, next) => {
console.log(req.path, req.method)
next()
})
// routes
app.use('/api/workouts', workoutRoutes)
// connect to db
mongoose.connect(process.env.MONGO_URI)
.then(() => {
// listen for requests
app.listen(process.env.PORT, () => {
console.log('connected to db & listening on port', process.env.PORT)
})
})
.catch((error) => {
console.log(error)
})
#5 – Models & Schemas
In this MERN tutorial you’ll create a new model & schema for the dtabase collection we’ll be using (workouts).
- 建立 models 資料夾
- 在 models 資料夾裡面建立 workoutModel.js 檔案
- 修改 workoutModel.js 檔案
- 修改 workouts.js 檔案
- 打開終端機查看 server.js 是否正常執行
- 使用 API 測試工具 Postman
localhost:4000/api/workouts/、POST 方法
Body > raw > JSON - 講解物件少了一個屬性會產生錯誤
- 使用 MongoDB Atlas
DEPLOYMENT > Database > Browse Collections
查看是否有正確儲存到線上資料庫
// backend/models/workoutModel.js
const mongoose = require('mongoose')
const Schema = mongoose.Schema
const workoutSchema = new Schema({
title: {
type:String,
required: true
},
reps: {
type: Number,
required: true
},
load: {
type: Number,
required: true
}
}, { timestamps: true })
module.exports = mongoose.model('Workout', workoutSchema)
// routes/workouts.js
const express = require('express')
const Workout = require('../models/workoutModel')
const router = express.Router()
// GET all workouts
router.get('/', (req, res) => {
res.json({mssg: 'GET all workouts'})
})
// GET a single workout
router.get('/:id', (req, res) => {
res.json({mssg: 'GET a single workout'})
})
// POST a new workout
router.post('/', async (req, res) => {
const { title, load, reps } = req.body
try {
const workout = await Workout.create({title, load, reps})
res.status(200).json(workout)
} catch (error) {
res.status(400).json({ error: error.message })
}
})
// DELETE a workout
router.delete('/:id', (req, res) => {
res.json({mssg: 'DELETE a workout'})
})
// UPDATE a workout
router.patch('/:id', (req, res) => {
res.json({mssg: 'UPDATE a workout'})
})
module.exports = router
// Body > raw > JSON
{
"title": "Situps",
"load": 0,
"reps": 50
}
#6 – Controllers (part 1)
In this MERN tutorial we’ll make some controller functions for the workout routes.
- 在 backend 資料夾裡面建立 controllers 資料夾
- 在 controllers 資料夾裡面建立 workoutController.js 檔案
- 修改 workouts.js 檔案
- 修改 workoutController.js 檔案
- 使用 API 測試工具 Postman 測試
localhost:4000/api/workouts/,POST 方法
新增資料內容
Body > raw > JSON - 複製剛建立好的資料庫資料的id
- 使用 API 測試工具
http://localhost:4000/api/workouts/,GET 方法 - 使用 API 測試工具取得特定資料
http://localhost:4000/api/workouts/666ffbd9a0facf510722c94c,GET 方法
http://localhost:4000/api/workouts/43423,GET 方法 - 修改 workoutController.js 檔案,mongoose
- 再次使用 API 測試工具測試
// backend/controllers/workoutController.js
const Workout = require('../models/workoutModel')
const mongoose = require('mongoose')
// get all workouts
const getWorkouts = async (req, res) => {
const workouts = await Workout.find({}).sort({createdAt: -1})
res.status(200).json(workouts)
}
// get a single workout
const getWorkout = async (req, res) => {
const { id } = req.params
if (!mongoose.Types.ObjectId.isValid(id)) {
return res.status(404).json({error: 'No such workout'})
}
const workout = await Workout.findById(id)
if (!workout) {
return res.status(404).json({error: 'No such workout'})
}
res.status(200).json(workout)
}
// create new workout
const createWorkout = async (req, res) => {
const { title, load, reps } = req.body
// add doc to db
try {
const workout = await Workout.create({title, load, reps})
res.status(200).json(workout)
} catch (error) {
res.status(400).json({ error: error.message })
}
}
// delete a workout
// update a workout
module.exports = {
getWorkouts,
getWorkout,
createWorkout
}
// backend/workouts.js
const express = require('express')
const {
createWorkout,
getWorkouts,
getWorkout
} = require('../controllers/workoutController')
const router = express.Router()
// GET all workouts
router.get('/', getWorkouts)
// GET a single workout
router.get('/:id', getWorkout)
// POST a new workout
router.post('/', createWorkout)
// DELETE a workout
router.delete('/:id', (req, res) => {
res.json({mssg: 'DELETE a workout'})
})
// UPDATE a workout
router.patch('/:id', (req, res) => {
res.json({mssg: 'UPDATE a workout'})
})
module.exports = router
// Body > raw > JSON
{
"title": "Bench press",
"load": 20,
"reps": 40
}
#7 – Controllers (part 2)
In this MERN auth tutorial we’ll finish off the controller functions that we started in the last lesson.
- 修改 workoutController.js 檔案,delete、update
- 修改 workouts.js 檔案
- 使用 API 測試工具 Postman,PATCH 方法
取得其中一筆資料 id
http://localhost:4000/api/workouts/666ffbd9a0facf510722c94c,PATCH 方法
Body > raw > JSON - 使用 API 測試工具,GET 方法
查看資料是否有修改成功 - 使用 API 測試工具 Postman,DELETE 方法
取得其中一筆要刪除資料的 id
http://localhost:4000/api/workouts/666ffbd9a0facf510722c94c,DELETE 方法 - 使用 API 測試工具,GET 方法
查看資料是否有刪除成功
// backend/controllers/workoutController.js
const Workout = require('../models/workoutModel')
const mongoose = require('mongoose')
// get all workouts
const getWorkouts = async (req, res) => {
const workouts = await Workout.find({}).sort({createdAt: -1})
res.status(200).json(workouts)
}
// get a single workout
const getWorkout = async (req, res) => {
const { id } = req.params
if (!mongoose.Types.ObjectId.isValid(id)) {
return res.status(404).json({error: 'No such workout'})
}
const workout = await Workout.findById(id)
if (!workout) {
return res.status(404).json({error: 'No such workout'})
}
res.status(200).json(workout)
}
// create new workout
const createWorkout = async (req, res) => {
const { title, load, reps } = req.body
// add doc to db
try {
const workout = await Workout.create({title, load, reps})
res.status(200).json(workout)
} catch (error) {
res.status(400).json({ error: error.message })
}
}
// delete a workout
const deleteWorkout = async (req, res) => {
const { id } = req.params
if (!mongoose.Types.ObjectId.isValid(id)) {
return res.status(404).json({error: 'No such workout'})
}
const workout = await Workout.findOneAndDelete({_id: id})
if (!workout) {
return res.status(404).json({error: 'No such workout'})
}
res.status(200).json(workout)
}
// update a workout
const updateWorkout = async (req, res) => {
const { id } = req.params
if (!mongoose.Types.ObjectId.isValid(id)) {
return res.status(404).json({error: 'No such workout'})
}
const workout = await Workout.findOneAndUpdate({_id: id}, {
...req.body
})
if (!workout) {
return res.status(404).json({error: 'No such workout'})
}
res.status(200).json(workout)
}
module.exports = {
getWorkouts,
getWorkout,
createWorkout,
deleteWorkout,
updateWorkout
}
// backend/routes/workouts.js
const express = require('express')
const {
createWorkout,
getWorkouts,
getWorkout,
deleteWorkout,
updateWorkout
} = require('../controllers/workoutController')
const router = express.Router()
// GET all workouts
router.get('/', getWorkouts)
// GET a single workout
router.get('/:id', getWorkout)
// POST a new workout
router.post('/', createWorkout)
// DELETE a workout
router.delete('/:id', deleteWorkout)
// UPDATE a workout
router.patch('/:id', updateWorkout)
module.exports = router
// Body > raw > JSON
{
"reps": 50
}
#8 – Making a React App
In this MERN tutorial, we’ll start our React application and set up a homepage route.
- 終止後端 server 運行
- 在 mern stack 專案下建立 React App
npx create-react-app frontend - 刪除 App.css、App.test.js、logo.svg、reportWebVitals.js、setupTests.js 檔案
- 修改 index.js 檔案
- 修改 App.js 檔案
- 安裝 react router 套件
移動到 frontend 資料夾位置
npm install react-router-dom - 修改 App.js 檔案,匯入 react-router-dom
- 在 src 資料夾裡面建立 pages 資料夾
- 在 pages 資料夾裡面建立 Home.js 檔案
- 修改 Home.js 檔案
- 修改 App.js 檔案
- 打開終端機執行指令 – npm start
http://localhost:3000/ - 在 src 資料夾裡面建立 components 資料夾
- 在 components 資料夾裡面建立 Navbar.js 檔案
- 修改 Navbar.js 檔案
- 修改 App.js 檔案,匯入 Navbar
- 修改 index.css 檔案
- 檢查網頁畫面是否正常
// frontend/src/index.js
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
)
// frontend/src/App.js
import { BrowserRouter, Routes, Route } from 'react-router-dom'
// pages & components
import Home from './pages/Home'
import Navbar from './components/Navbar'
function App() {
return (
<div className="App">
<BrowserRouter>
<Navbar />
<div className="pages">
<Routes>
<Route
path="/"
element={<Home />}
/>
</Routes>
</div>
</BrowserRouter>
</div>
);
}
export default App;
// forntend/src/pages/Home.js
const Home = () => {
return (
<div className="home">
<h2>Home</h2>
</div>
)
}
export default Home
// frontend/src/components/Navbar.js
import { Link } from 'react-router-dom'
const Navbar = () => {
return (
<header>
<div className="container">
<Link to="/">
<h1>Workout Buddy</h1>
</Link>
</div>
</header>
)
}
export default Navbar
// frontend/src/index.css
/* google font */
@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;800&family=VT323&display=swap');
/* layout */
:root {
--primary: #1aac83;
--error: #e7195a;
}
body {
background: #f1f1f1;
margin: 0;
font-family: "Poppins";
}
header {
background: #fff;
}
header .container {
max-width: 1400px;
margin: 0 auto;
padding: 10px 20px;
display: flex;
align-items: center;
justify-content: space-between;
}
header a {
color: #333;
text-decoration: none;
}
.pages{
max-width: 1400px;
padding: 20px;
margin: 0 auto;
}
#9 – Fetching Data
In this MERN stack tutorial we’ll make a request to the backend api to fetch workouts data and output it in the React template.
- 修改 Home.js 檔案
- 運行後端 server
移動到 backend 資料夾
終端機指令運行 npm run dev - 查看 Google Console,有 Cors 錯誤
- 修改 package.json 檔案,新增 proxy 屬性、值
- 修改 Home.js 檔案
- 前端終端機重新運行
- 在 components 資料夾裡面建立 WorkoutDetails.js 檔案
- 修改 Home.js 檔案
- 修改 WorkoutDetails.js 檔案
- 注意: 需加上 return 才不會出現錯誤
- 修改 index.css 檔案
// frontend/src/pages/Home.js
import { useEffect, useState } from 'react'
// components
import WorkoutDetails from '../components/WorkoutDetails'
const Home = () => {
const [workouts, setWorkouts] = useState(null)
useEffect(() => {
const fetchWorkouts = async () => {
const response = await fetch('/api/workouts')
const json = await response.json()
if (response.ok) {
setWorkouts(json)
}
}
fetchWorkouts()
}, [])
return (
<div className="home">
<div className="workouts">
{workouts && workouts.map((workout) => {
return <WorkoutDetails key={workout._id} workout={workout} />
})}
</div>
</div>
)
}
export default Home
// frontend/package.json
{
"proxy": "http://localhost:4000",
"name": "frontend",
"version": "0.1.0",
"private": true,
"dependencies": {
"@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.23.1",
"react-scripts": "5.0.1",
"web-vitals": "^2.1.4"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}
// frontend/src/components/WorkoutDetails.js
const WorkoutDetails = ({ workout }) => {
return (
<div className="workout-details">
<h4>{workout.title}</h4>
<p><strong>Load (kg): </strong>{workout.load}</p>
<p><strong>Reps: </strong>{workout.reps}</p>
<p>{workout.createdAt}</p>
</div>
)
}
export default WorkoutDetails
// frontend/src/index.css
/* google font */
@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;800&family=VT323&display=swap');
/* layout */
:root {
--primary: #1aac83;
--error: #e7195a;
}
body {
background: #f1f1f1;
margin: 0;
font-family: "Poppins";
}
header {
background: #fff;
}
header .container {
max-width: 1400px;
margin: 0 auto;
padding: 10px 20px;
display: flex;
align-items: center;
justify-content: space-between;
}
header a {
color: #333;
text-decoration: none;
}
.pages{
max-width: 1400px;
padding: 20px;
margin: 0 auto;
}
/* homepage */
.home {
display: grid;
grid-template-columns: 3fr 1fr;
gap: 100px;
}
.workout-details {
background: #fff;
border-radius: 4px;
margin: 20px auto;
padding: 20px;
position: relative;
box-shadow: 2px 2px 5px rgba(0,0,0,0.05);
}
.workout-details h4 {
margin: 0 0 10px 0;
font-size: 1.2em;
color: var(--primary);
}
.workout-details p {
margin: 0;
font-size: 0.9em;
color: #555;
}
.workout-details span {
position: absolute;
top: 20px;
right: 20px;
cursor: pointer;
background: #f1f1f1;
padding: 6px;
border-radius: 50%;
color: #333;
}
#10 – New Workout Form
In this lesson we’ll make a form to add new workouts.
- 在 components 資料夾裡面建立 WorkoutForm.js 檔案
- 修改 WorkoutForm.js 檔案
- 查看後端 server.js 檔案,routes
- 修改 WorkoutForm.js 檔案,response
- 查看後端 workoutController.js 檔案
- 修改 WorkoutForm.js 檔案,error
- 修改 Home.js 檔案
- 修改 WorkoutForm.js 檔案
- 在網頁測試增加功能,資料不完整與完整的差別
- 修改 index.css 檔案
- 在網頁測試增加功能,樣式修改後的變化
// forntend/src/components/WorkoutForm.js
import { useState } from "react"
const WorkoutForm = () => {
const [title, setTitle] = useState('')
const [load, setLoad] = useState('')
const [reps, setReps] = useState('')
const [error, setError] = useState(null)
const handleSubmit = async (e) => {
e.preventDefault()
const workout = {title, load, reps}
const response = await fetch('/api/workouts', {
method: 'POST',
body: JSON.stringify(workout),
headers: {
'Content-Type': 'application/json'
}
})
const json = await response.json()
if (!response.ok) {
setError(json.error)
}
if (response.ok) {
setTitle('')
setLoad('')
setReps('')
setError(null)
console.log('new workout added', json);
}
}
return (
<form className="create" onSubmit={handleSubmit}>
<h3>Add a New Workout</h3>
<label>Exercise Title:</label>
<input
type="text"
onChange={(e) => setTitle(e.target.value)}
value={title}
/>
<label>Load (in kg):</label>
<input
type="number"
onChange={(e) => setLoad(e.target.value)}
value={load}
/>
<label>Reps:</label>
<input
type="number"
onChange={(e) => setReps(e.target.value)}
value={reps}
/>
<button>Add Workout</button>
{error && <div className="error">{error}</div>}
</form>
)
}
export default WorkoutForm
// frontend/src/pages/Home.js
import { useEffect, useState } from 'react'
// components
import WorkoutDetails from '../components/WorkoutDetails'
import WorkoutForm from '../components/WorkoutForm'
const Home = () => {
const [workouts, setWorkouts] = useState(null)
useEffect(() => {
const fetchWorkouts = async () => {
const response = await fetch('/api/workouts')
const json = await response.json()
if (response.ok) {
setWorkouts(json)
}
}
fetchWorkouts()
}, [])
return (
<div className="home">
<div className="workouts">
{workouts && workouts.map((workout) => {
return <WorkoutDetails key={workout._id} workout={workout} />
})}
</div>
<WorkoutForm />
</div>
)
}
export default Home
// frontend/src/index.css
/* google font */
@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;800&family=VT323&display=swap');
/* layout */
:root {
--primary: #1aac83;
--error: #e7195a;
}
body {
background: #f1f1f1;
margin: 0;
font-family: "Poppins";
}
header {
background: #fff;
}
header .container {
max-width: 1400px;
margin: 0 auto;
padding: 10px 20px;
display: flex;
align-items: center;
justify-content: space-between;
}
header a {
color: #333;
text-decoration: none;
}
.pages{
max-width: 1400px;
padding: 20px;
margin: 0 auto;
}
/* homepage */
.home {
display: grid;
grid-template-columns: 3fr 1fr;
gap: 100px;
}
.workout-details {
background: #fff;
border-radius: 4px;
margin: 20px auto;
padding: 20px;
position: relative;
box-shadow: 2px 2px 5px rgba(0,0,0,0.05);
}
.workout-details h4 {
margin: 0 0 10px 0;
font-size: 1.2em;
color: var(--primary);
}
.workout-details p {
margin: 0;
font-size: 0.9em;
color: #555;
}
.workout-details span {
position: absolute;
top: 20px;
right: 20px;
cursor: pointer;
background: #f1f1f1;
padding: 6px;
border-radius: 50%;
color: #333;
}
/* new workout form */
label, input {
display: block;
}
input {
padding: 10px;
margin-top: 10px;
margin-bottom: 20px;
width: 100%;
border: 1px solid #ddd;
border-radius: 4px;
box-sizing: border-box;
}
form button {
background: var(--primary);
border: 0;
color: #fff;
padding: 10px;
font-family: "Poppins";
border-radius: 4px;
cursor: pointer;
}
div.error {
padding: 10px;
background: #ffefef;
border: 1px solid var(--error);
color: var(--error);
border-radius: 4px;
margin: 20px 0;
}
#11 – Adding React Context
In this MERN tutorial we’ll create a React Context to provide some global workouts state to the entire React application
- 在 src 資料夾裡面建立 context 資料夾
- 在 context 資料夾裡面建立 WorkoutContext.js 檔案
- 修改 WorkoutContext.js 檔案
- 修改 index.js 檔案
- 修改 WorkoutContext.js 檔案
- 在 src 資料夾裡面建立 hooks 資料夾
- 在 hooks 資料夾裡面建立 useWorkoutsContext.js 檔案
- 修改 useWorkoutsContext.js 檔案
- 講解 Home.js 檔案
- 講解 WorkoutContext.js 檔案
- 修改 Home.js 檔案,匯入 useWorkoutsContext、移除 useState
- 修改 WorkoutForm.js 檔案
// frontend/src/context/WorkoutContext.js
import { createContext, useReducer } from 'react'
export const WorkoutsContext = createContext()
export const workoutsReducer = (state, action) => {
switch (action.type) {
case 'SET_WORKOUTS':
return {
workouts: action.payload
}
case 'CREATE_WORKOUT':
return {
workouts: [action.payload, ...state.workouts]
}
default:
return state
}
}
export const WorkoutsContextProvider = ({ children }) => {
const [state, dispatch] = useReducer(workoutsReducer, {
workouts: null
})
return (
<WorkoutsContext.Provider value={{...state, dispatch}}>
{ children }
</WorkoutsContext.Provider>
)
}
// frontend/src/index.js
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import { WorkoutsContextProvider } from './context/WorkoutContext';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<WorkoutsContextProvider>
<App />
</WorkoutsContextProvider>
</React.StrictMode>
);
// frontend/src/hooks/useWorkoutsContext.js
import { WorkoutsContext } from "../context/WorkoutContext";
import { useContext } from 'react'
export const useWorkoutsContext = () => {
const context = useContext(WorkoutsContext)
if (!context) {
throw Error('useWorkoutsContext must be used inside an WorkoutsContextProvider')
}
return context
}
// frontend/src/pages/Home.js
import { useEffect } from 'react'
import { useWorkoutsContext } from '../hooks/useWorkoutsContext'
// components
import WorkoutDetails from '../components/WorkoutDetails'
import WorkoutForm from '../components/WorkoutForm'
const Home = () => {
const {workouts, dispatch} = useWorkoutsContext()
useEffect(() => {
const fetchWorkouts = async () => {
const response = await fetch('/api/workouts')
const json = await response.json()
if (response.ok) {
dispatch({type: 'SET_WORKOUTS', payload: json})
}
}
fetchWorkouts()
}, [dispatch])
return (
<div className="home">
<div className="workouts">
{workouts && workouts.map((workout) => {
return <WorkoutDetails key={workout._id} workout={workout} />
})}
</div>
<WorkoutForm />
</div>
)
}
export default Home
// frontend/src/components/WorkoutForm.js
import { useState } from "react"
import { useWorkoutsContext } from "../hooks/useWorkoutsContext"
const WorkoutForm = () => {
const { dispatch } = useWorkoutsContext()
const [title, setTitle] = useState('')
const [load, setLoad] = useState('')
const [reps, setReps] = useState('')
const [error, setError] = useState(null)
const handleSubmit = async (e) => {
e.preventDefault()
const workout = {title, load, reps}
const response = await fetch('/api/workouts', {
method: 'POST',
body: JSON.stringify(workout),
headers: {
'Content-Type': 'application/json'
}
})
const json = await response.json()
if (!response.ok) {
setError(json.error)
}
if (response.ok) {
setTitle('')
setLoad('')
setReps('')
setError(null)
console.log('new workout added', json);
dispatch({type: 'CREATE_WORKOUT', payload: json})
}
}
return (
<form className="create" onSubmit={handleSubmit}>
<h3>Add a New Workout</h3>
<label>Exercise Title:</label>
<input
type="text"
onChange={(e) => setTitle(e.target.value)}
value={title}
/>
<label>Load (in kg):</label>
<input
type="number"
onChange={(e) => setLoad(e.target.value)}
value={load}
/>
<label>Reps:</label>
<input
type="number"
onChange={(e) => setReps(e.target.value)}
value={reps}
/>
<button>Add Workout</button>
{error && <div className="error">{error}</div>}
</form>
)
}
export default WorkoutForm
#12 – Deleting Data
- 修改 WorkoutDetails.js 檔案
- 修改 WorkoutContext.js 檔案
// frontend/src/components/WorkoutDetails.js
import { useWorkoutsContext } from '../hooks/useWorkoutsContext'
const WorkoutDetails = ({ workout }) => {
const { dispatch } = useWorkoutsContext()
const handleClick = async () => {
const response = await fetch('/api/workouts/' + workout._id, {
method: 'DELETE'
})
const json = await response.json()
if (response.ok) {
dispatch({type: 'DELETE_WORKOUT', payload: json})
}
}
return (
<div className="workout-details">
<h4>{workout.title}</h4>
<p><strong>Load (kg): </strong>{workout.load}</p>
<p><strong>Reps: </strong>{workout.reps}</p>
<p>{workout.createdAt}</p>
<span onClick={handleClick}>delete</span>
</div>
)
}
export default WorkoutDetails
// frontend/src/context/WorkoutContext.js
import { createContext, useReducer } from 'react'
export const WorkoutsContext = createContext()
export const workoutsReducer = (state, action) => {
switch (action.type) {
case 'SET_WORKOUTS':
return {
workouts: action.payload
}
case 'CREATE_WORKOUT':
return {
workouts: [action.payload, ...state.workouts]
}
case 'DELETE_WORKOUT':
return {
workouts: state.workouts.filter((w) => w._id !== action.payload._id)
}
default:
return state
}
}
export const WorkoutsContextProvider = ({ children }) => {
const [state, dispatch] = useReducer(workoutsReducer, {
workouts: null
})
return (
<WorkoutsContext.Provider value={{...state, dispatch}}>
{ children }
</WorkoutsContext.Provider>
)
}
#13 – Handling Error Responses
- 講解後端 workoutController.js 檔案
- 講解後端 workoutModel.js 檔案
- 修改 workoutController.js 檔案
- 修改 WorkoutForm.js 檔案
- 修改 index.css 檔案
// backend/controllers/workoutController.js
const Workout = require('../models/workoutModel')
const mongoose = require('mongoose')
// get all workouts
const getWorkouts = async (req, res) => {
const workouts = await Workout.find({}).sort({createdAt: -1})
res.status(200).json(workouts)
}
// get a single workout
const getWorkout = async (req, res) => {
const { id } = req.params
if (!mongoose.Types.ObjectId.isValid(id)) {
return res.status(404).json({error: 'No such workout'})
}
const workout = await Workout.findById(id)
if (!workout) {
return res.status(404).json({error: 'No such workout'})
}
res.status(200).json(workout)
}
// create new workout
const createWorkout = async (req, res) => {
const { title, load, reps } = req.body
let emptyFields = []
if(!title) {
emptyFields.push('title')
}
if(!load) {
emptyFields.push('load')
}
if(!reps) {
emptyFields.push('reps')
}
if(emptyFields.length > 0) {
return res.status(400).json({ error: 'Please fill in all fields', emptyFields })
}
// add doc to db
try {
const workout = await Workout.create({title, load, reps})
res.status(200).json(workout)
} catch (error) {
res.status(400).json({ error: error.message })
}
}
// delete a workout
const deleteWorkout = async (req, res) => {
const { id } = req.params
if (!mongoose.Types.ObjectId.isValid(id)) {
return res.status(404).json({error: 'No such workout'})
}
const workout = await Workout.findOneAndDelete({_id: id})
if (!workout) {
return res.status(404).json({error: 'No such workout'})
}
res.status(200).json(workout)
}
// update a workout
const updateWorkout = async (req, res) => {
const { id } = req.params
if (!mongoose.Types.ObjectId.isValid(id)) {
return res.status(404).json({error: 'No such workout'})
}
const workout = await Workout.findOneAndUpdate({_id: id}, {
...req.body
})
if (!workout) {
return res.status(404).json({error: 'No such workout'})
}
res.status(200).json(workout)
}
module.exports = {
getWorkouts,
getWorkout,
createWorkout,
deleteWorkout,
updateWorkout
}
// frontend/src/components/WorkoutForm.js
import { useState } from "react"
import { useWorkoutsContext } from "../hooks/useWorkoutsContext"
const WorkoutForm = () => {
const { dispatch } = useWorkoutsContext()
const [title, setTitle] = useState('')
const [load, setLoad] = useState('')
const [reps, setReps] = useState('')
const [error, setError] = useState(null)
const [emptyFields, setEmptyFields] = useState([])
const handleSubmit = async (e) => {
e.preventDefault()
const workout = {title, load, reps}
const response = await fetch('/api/workouts', {
method: 'POST',
body: JSON.stringify(workout),
headers: {
'Content-Type': 'application/json'
}
})
const json = await response.json()
if (!response.ok) {
setError(json.error)
setEmptyFields(json.emptyFields)
}
if (response.ok) {
setTitle('')
setLoad('')
setReps('')
setError(null)
setEmptyFields([])
console.log('new workout added', json);
dispatch({type: 'CREATE_WORKOUT', payload: json})
}
}
return (
<form className="create" onSubmit={handleSubmit}>
<h3>Add a New Workout</h3>
<label>Exercise Title:</label>
<input
type="text"
onChange={(e) => setTitle(e.target.value)}
value={title}
className={emptyFields.includes('title') ? 'error' : ''}
/>
<label>Load (in kg):</label>
<input
type="number"
onChange={(e) => setLoad(e.target.value)}
value={load}
className={emptyFields.includes('load') ? 'error' : ''}
/>
<label>Reps:</label>
<input
type="number"
onChange={(e) => setReps(e.target.value)}
value={reps}
className={emptyFields.includes('reps') ? 'error' : ''}
/>
<button>Add Workout</button>
{error && <div className="error">{error}</div>}
</form>
)
}
export default WorkoutForm
// frontend/src/index.css
/* google font */
@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;800&family=VT323&display=swap');
/* layout */
:root {
--primary: #1aac83;
--error: #e7195a;
}
body {
background: #f1f1f1;
margin: 0;
font-family: "Poppins";
}
header {
background: #fff;
}
header .container {
max-width: 1400px;
margin: 0 auto;
padding: 10px 20px;
display: flex;
align-items: center;
justify-content: space-between;
}
header a {
color: #333;
text-decoration: none;
}
.pages{
max-width: 1400px;
padding: 20px;
margin: 0 auto;
}
/* homepage */
.home {
display: grid;
grid-template-columns: 3fr 1fr;
gap: 100px;
}
.workout-details {
background: #fff;
border-radius: 4px;
margin: 20px auto;
padding: 20px;
position: relative;
box-shadow: 2px 2px 5px rgba(0,0,0,0.05);
}
.workout-details h4 {
margin: 0 0 10px 0;
font-size: 1.2em;
color: var(--primary);
}
.workout-details p {
margin: 0;
font-size: 0.9em;
color: #555;
}
.workout-details span {
position: absolute;
top: 20px;
right: 20px;
cursor: pointer;
background: #f1f1f1;
padding: 6px;
border-radius: 50%;
color: #333;
}
/* new workout form */
label, input {
display: block;
}
input {
padding: 10px;
margin-top: 10px;
margin-bottom: 20px;
width: 100%;
border: 1px solid #ddd;
border-radius: 4px;
box-sizing: border-box;
}
form button {
background: var(--primary);
border: 0;
color: #fff;
padding: 10px;
font-family: "Poppins";
border-radius: 4px;
cursor: pointer;
}
div.error {
padding: 10px;
background: #ffefef;
border: 1px solid var(--error);
color: var(--error);
border-radius: 4px;
margin: 20px 0;
}
input.error {
border: 1px solid var(--error);
}
#14 – Finishing Touches
- 修改 index.html 檔案,匯入 Google Material Symbols & Icons
- 修改 WorkoutDetails.js 檔案
- 測試刪除按鈕是否能正常運作
- 使用、安裝 date-fns 套件
npm install date-fns - 修改 WorkoutDetails.js 檔案
- 修改 Home.js 檔案 (Debug),dispatch 之前已自行修正
// frontend/public/index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@48,400,0,0" />
<title>React App</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>
// frontend/src/components/WorkoutDetails.js
import { useWorkoutsContext } from '../hooks/useWorkoutsContext'
// date fns
import formatDistanceToNow from 'date-fns/formatDistanceToNow'
const WorkoutDetails = ({ workout }) => {
const { dispatch } = useWorkoutsContext()
const handleClick = async () => {
const response = await fetch('/api/workouts/' + workout._id, {
method: 'DELETE'
})
const json = await response.json()
if (response.ok) {
dispatch({type: 'DELETE_WORKOUT', payload: json})
}
}
return (
<div className="workout-details">
<h4>{workout.title}</h4>
<p><strong>Load (kg): </strong>{workout.load}</p>
<p><strong>Reps: </strong>{workout.reps}</p>
<p>{formatDistanceToNow(new Date(workout.createdAt), { addSuffix: true })}</p>
<span className='material-symbols-outlined' onClick={handleClick}>delete</span>
</div>
)
}
export default WorkoutDetails