wordpress_blog

This is a dynamic to static website.

MERN Stack Tutorial

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