เราจะทำอะไรใน Workshop นี้
เราจะต่อยอดจาก “โปรเจกต์ธีมเดิม” (ที่มี Layout/Sidebar/Routes อยู่แล้ว) เพื่อสร้างฟีเจอร์ใหม่:
- หน้า /posts ดึงรายการโพสต์จาก API แล้วแสดงแบบ Card + ค้นหา
- หน้า /posts/:id แสดงรายละเอียดโพสต์ (Dynamic Route)
- มี Loading / Error UI ที่ reuse ได้
เตรียมโปรเจกต์ (ต่อจากของเดิม) ให้ดาวน์โหลด initial project ตามที่ผู้สอนกำหนด หรือใช้ โปรเจคจากครั้งที่ผ่านมา
- เปิดโฟลเดอร์โปรเจกต์ธีมเดิม
- ติดตั้งแพ็กเกจ (ถ้ายังไม่ได้ติดตั้ง)
npm install
npm install react-router-dom
สร้างโครงสร้างไฟล์
สร้างโฟลเดอร์และไฟล์ตามนี้:
src/
pages/
Posts.jsx
PostDetail.jsx
services/
postService.js
components/
Loading.jsx
ErrorMessage.jsx
สร้าง Service สำหรับเรียก API
สร้างไฟล์ src/services/postService.js เพื่อรวมฟังก์ชันเรียก API ไว้ที่เดียว
const BASE_URL = "https://jsonplaceholder.typicode.com";
export async function getPosts() {
const res = await fetch(`${BASE_URL}/posts`);
if (!res.ok) throw new Error("Failed to fetch posts");
return res.json();
}
export async function getPostById(id) {
const res = await fetch(`${BASE_URL}/posts/${id}`);
if (!res.ok) throw new Error("Failed to fetch post detail");
return res.json();
}
สร้าง Loading และ Error Components
4.1 สร้าง src/components/Loading.jsx
export default function Loading({ text = "Loading..." }) {
return (
<div className="d-flex align-items-center gap-2 py-4">
<div className="spinner-border" role="status" aria-hidden="true" />
<div>{text}</div>
</div>
);
}
4.2 สร้าง src/components/ErrorMessage.jsx
export default function ErrorMessage({ message = "Something went wrong" }) {
return (
<div className="alert alert-danger my-3" role="alert">
{message}
</div>
);
}
สร้างหน้า Posts (List + Search + Loading/Error)
สร้างไฟล์ src/pages/Posts.jsx เพื่อ: ดึงข้อมูลโพสต์ → แสดงผลเป็น Card → มีช่องค้นหา title
import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { getPosts } from "../services/postService";
import Loading from "../components/Loading";
import ErrorMessage from "../components/ErrorMessage";
export default function Posts() {
const [posts, setPosts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [q, setQ] = useState("");
useEffect(() => {
let ignore = false;
async function load() {
try {
setLoading(true);
setError("");
const data = await getPosts();
if (!ignore) setPosts(data);
} catch (e) {
if (!ignore) setError(e?.message || "Fetch error");
} finally {
if (!ignore) setLoading(false);
}
}
load();
return () => {
ignore = true;
};
}, []);
const filtered = useMemo(() => {
const keyword = q.trim().toLowerCase();
if (!keyword) return posts;
return posts.filter((p) => p.title.toLowerCase().includes(keyword));
}, [posts, q]);
return (
<div className="container-fluid">
<div className="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-3">
<h1 className="h4 m-0">Posts</h1>
<input
className="form-control"
style={{ maxWidth: 360 }}
placeholder="Search title..."
value={q}
onChange={(e) => setQ(e.target.value)}
/>
</div>
{loading && <Loading text="Fetching posts..." />}
{error && <ErrorMessage message={error} />}
{!loading && !error && (
<div className="row g-3">
{filtered.slice(0, 12).map((p) => (
<div className="col-12 col-md-6 col-xl-4" key={p.id}>
<div className="card h-100 shadow-sm">
<div className="card-body">
<div className="d-flex justify-content-between align-items-start gap-2">
<h2 className="h6">{p.title}</h2>
<span className="badge text-bg-light">#{p.id}</span>
</div>
<p className="text-muted mb-3">
{p.body.length > 90 ? p.body.slice(0, 90) + "..." : p.body}
</p>
<Link className="btn btn-outline-primary btn-sm" to={`/posts/${p.id}`}>
View Detail
</Link>
</div>
</div>
</div>
))}
</div>
)}
{!loading && !error && filtered.length === 0 && (
<div className="alert alert-warning mt-3">No results.</div>
)}
</div>
);
}
สร้างหน้า PostDetail (Dynamic Route)
สร้างไฟล์ src/pages/PostDetail.jsx เพื่ออ่านค่า id จาก URL แล้วดึงข้อมูลรายละเอียด
import { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { getPostById } from "../services/postService";
import Loading from "../components/Loading";
import ErrorMessage from "../components/ErrorMessage";
export default function PostDetail() {
const { id } = useParams();
const [post, setPost] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
let ignore = false;
async function load() {
try {
setLoading(true);
setError("");
const data = await getPostById(id);
if (!ignore) setPost(data);
} catch (e) {
if (!ignore) setError(e?.message || "Fetch error");
} finally {
if (!ignore) setLoading(false);
}
}
load();
return () => {
ignore = true;
};
}, [id]);
return (
<div className="container-fluid">
<div className="d-flex align-items-center justify-content-between mb-3">
<h1 className="h4 m-0">Post Detail</h1>
<Link className="btn btn-outline-secondary btn-sm" to="/posts">
← Back
</Link>
</div>
{loading && <Loading text={`Loading post #${id}...`} />}
{error && <ErrorMessage message={error} />}
{!loading && !error && post && (
<div className="card shadow-sm">
<div className="card-body">
<div className="d-flex justify-content-between align-items-start gap-2">
<h2 className="h5">{post.title}</h2>
<span className="badge text-bg-light">#{post.id}</span>
</div>
<p className="text-muted mb-0">{post.body}</p>
</div>
</div>
)}
</div>
);
}
เพิ่ม Routes ให้เข้ากับธีมเดิม (ดูโฟลเดอร์ที่เกี่ยวข้องกับ Route)
ตัวอย่าง (React Router v6):
import { Routes, Route } from "react-router-dom";
import Posts from "../pages/Posts";
import PostDetail from "../pages/PostDetail";
// ...ภายใน component route หลัก
<Routes>
{/* routes เดิมของธีม เช่น Dashboard, About, etc. */}
<Route path="/posts" element={<Posts />} />
<Route path="/posts/:id" element={<PostDetail />} />
</Routes>
แผนภาพการไหลของ Routes
เพิ่มเมนูใน Sidebar (ธีมเดิม)
ใน Sidebar ของธีมเดิม (เช่นใช้ NavLink) ให้เพิ่มเมนูไปที่ /posts
{/* ตัวอย่างเมนูใน Sidebar */}
<NavLink className="nav-link d-flex align-items-center" to="/posts">
<i className="bi bi-journal-text me-2"></i>
<span className="nav-label">Posts</span>
</NavLink>
ทดสอบ + Debug ที่พบบ่อย
9.1 ทดสอบเส้นทาง
- เข้า /posts → ต้องเห็นรายการ + ช่องค้นหา
- ค้นหา เช่นพิมพ์ qui → จำนวนการ์ดลดลง
- กด View Detail → ไป /posts/1 (หรือ id อื่น) → เห็นรายละเอียด
9.2 ปัญหาที่พบบ่อย
งานท้าทาย (โบนัส)
- ทำ Pagination (ทีละ 5 รายการ) ด้วย state page และ slice()
- ทำ “Load more” เพิ่มทีละ 6 รายการ
- ทำ Skeleton loading แทน spinner
- แสดง userId และทำ Filter ตาม userId