React Tutorial Free API Router

Workshop : การดึงค่า API มาแสดงผล + ทำหน้า Detail

วิชา: Frontend Framework • ผู้สอน: ผู้ช่วยศาสตราจารย์ ปองพล นิลพฤกษ์

Overview

เราจะทำอะไรใน Workshop นี้

API: JSONPlaceholder

เราจะต่อยอดจาก “โปรเจกต์ธีมเดิม” (ที่มี Layout/Sidebar/Routes อยู่แล้ว) เพื่อสร้างฟีเจอร์ใหม่:

  • หน้า /posts ดึงรายการโพสต์จาก API แล้วแสดงแบบ Card + ค้นหา
  • หน้า /posts/:id แสดงรายละเอียดโพสต์ (Dynamic Route)
  • มี Loading / Error UI ที่ reuse ได้
API ที่ใช้: https://jsonplaceholder.typicode.com/posts (ฟรี ไม่ต้องใช้ key)
Step 1

เตรียมโปรเจกต์ (ต่อจากของเดิม) ให้ดาวน์โหลด initial project ตามที่ผู้สอนกำหนด หรือใช้ โปรเจคจากครั้งที่ผ่านมา


  1. เปิดโฟลเดอร์โปรเจกต์ธีมเดิม
  2. ติดตั้งแพ็กเกจ (ถ้ายังไม่ได้ติดตั้ง)
npm install
npm install react-router-dom
เปิดรันโปรเจกต์ได้ด้วย npm run dev หรือ npm start (ตามเครื่องมือที่ใช้)
Step 2

สร้างโครงสร้างไฟล์


สร้างโฟลเดอร์และไฟล์ตามนี้:

src/
  pages/
    Posts.jsx
    PostDetail.jsx
  services/
    postService.js
  components/
    Loading.jsx
    ErrorMessage.jsx
แนวคิด: แยก services (การเรียก API) ออกจาก pages (หน้าจอ) และ components (ชิ้นส่วนใช้ซ้ำ)
Step 3

สร้าง 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();
}
ตอนนี้เรามีฟังก์ชัน 2 ตัว: getPosts() และ getPostById(id)
Step 4

สร้าง 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>
  );
}
ทำเป็น Component แยก เพื่อใช้ซ้ำได้หลายหน้า (เช่น Products/Users ในอนาคต)
Step 5

สร้างหน้า 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>
  );
}
ทดสอบแล้วควรเห็น Post 12 รายการแรก + ค้นหา title ได้ + คลิกเข้า Detail ได้ (แต่ Route ยังไม่เพิ่มใน Step นี้ ให้ *ผู้เรียนลองทดสอบการเพิ่ม Route จากที่เรียนไปครั้งก่อน)
Step 6

สร้างหน้า 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>
  );
}
Step 7

เพิ่ม Routes ให้เข้ากับธีมเดิม (ดูโฟลเดอร์ที่เกี่ยวข้องกับ Route)


ในธีมเดิมของห้อง อาจมีไฟล์ Router หลักต่างกัน เช่น App.jsx, routes.jsx หรือ AppRoutes.jsx ให้เพิ่ม Route 2 ตัวนี้เข้าไปในตำแหน่งที่ธีมเดิมประกาศ <Routes>

ตัวอย่าง (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

Layout (ธีมเดิม) Sidebar + Topbar Outlet / Main Content Route: /posts Posts.jsx fetch list + search Route: /posts/:id PostDetail.jsx useParams(id) + fetch detail เมนู Sidebar → /posts คลิก View Detail → /posts/1 กด Back → /posts
เมื่อเพิ่ม Route แล้ว: ไปที่ /posts ได้ และคลิกเข้า /posts/1 ได้
Step 8

เพิ่มเมนูใน 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>
Step 9

ทดสอบ + Debug ที่พบบ่อย


9.1 ทดสอบเส้นทาง

  • เข้า /posts → ต้องเห็นรายการ + ช่องค้นหา
  • ค้นหา เช่นพิมพ์ qui → จำนวนการ์ดลดลง
  • กด View Detail → ไป /posts/1 (หรือ id อื่น) → เห็นรายละเอียด

9.2 ปัญหาที่พบบ่อย

Reload แล้ว 404 (เฉพาะบางโปรเจกต์)
ถ้าใช้ build แบบ static hosting ต้องตั้งค่าให้ server ส่ง index.html ทุก path (แต่ใน dev server มักไม่มีปัญหา)
ลืม import / path ผิด
ตรวจว่าไฟล์อยู่ตำแหน่งตาม Step 2 และ import ตรงกัน เช่น ../services/postService
ใช้ <a href> ทำให้หน้า reload
ใน SPA ให้ใช้ Link/NavLink แทน เพื่อเปลี่ยน route โดยไม่ refresh หน้า
Challenge

งานท้าทาย (โบนัส)


  • ทำ Pagination (ทีละ 5 รายการ) ด้วย state page และ slice()
  • ทำ “Load more” เพิ่มทีละ 6 รายการ
  • ทำ Skeleton loading แทน spinner
  • แสดง userId และทำ Filter ตาม userId