บทเรียน 84 · Frontend · Next.js App

RecipeCard และ 4 สถานะของหน้า — ปกติ · ว่าง · กำลังโหลด · error

~25 นาที · ควรผ่าน บทเรียน 83 (Layout และ Navbar) และ บทเรียน 71 (component กับ props) มาก่อน

แอป Wongnok มี navbar แล้ว ขั้นต่อไปในสไลด์คือหน้า All Recipes — หน้ารวมสูตรอาหารเป็นการ์ดเรียงกัน
บทนี้ทำสองเรื่องที่มือใหม่มักข้าม: (1) อ่านภาพดีไซน์แล้วแปลงเป็น type ของ props ว่าช่องไหนต้องมี ช่องไหนไม่มีก็ได้ ช่องไหนยังไม่ต้องทำ · (2) ออกแบบหน้าให้ครบ 4 สถานะ ไม่ใช่แค่ตอนที่ข้อมูลมาครบสวย ๆ
คนส่วนใหญ่เขียนแค่หน้าตอน "มีข้อมูล" แล้วไปเจอหน้าขาวโล่งตอนเน็ตช้า หรือตอน API ล่ม · ผมbuild โค้ดจริง แล้ว curl HTML ของหน้าทั้ง 4 สถานะมาวางให้เห็นว่ามัน render ต่างกันจริง

🧰 ของที่ใช้ตรวจในบทนี้

# npx next build
▲ Next.js 16.3.5 (Turbopack)
✓ Compiled successfully in 938ms
  Running TypeScript ...
  Finished TypeScript in 793ms ...
✓ Generating static pages using 6 workers (5/5) in 210ms
└ ƒ /recipes

# เวอร์ชันจริงบนเครื่องที่ใช้ตรวจ (อ่านจาก node_modules)
next            16.3.5
react           19.2.8
typescript      5.9.3
tailwindcss     4.3.3
@base-ui/react  1.8.0
shadcn          4.21.0
node            v24.14.0

โปรเจกต์ที่ใช้ตรวจสร้างด้วย create-next-app + npx shadcn init -d แล้ว npx shadcn add button card badge skeleton ·
· error ทุกบรรทัดและเลข TSxxxx ก็อปมาจาก npx tsc --noEmit ตัวจริง
· HTML ทุกก้อนได้จาก curl ไปที่ server จริง แล้วตัดเฉพาะส่วนใน <main>
· โค้ดทุกก้อนที่ไม่ได้ติดป้ายว่า "พัง" ผ่านทั้ง npx tsc --noEmit · npx eslint . · npx next build

สิ่งเดียวที่ตัดออกก่อนเอามาโชว์ คือ attribute class="…" ใน HTML ที่ curl มา — class ของ shadcn ยาวบรรทัดละหลายร้อยตัวอักษร จนมองโครงไม่เห็น · ถ้าก้อนไหนยังมี class อยู่ แปลว่าไม่ได้ตัด · ก้อนที่ยาวมากตัดบางส่วนเป็น … และบอกไว้ในก้อน · แท็ก ข้อความ และลำดับที่เหลืออยู่ ไม่ได้แก้แม้แต่ตัวเดียว
· server ที่ใช้ตรวจรันบนพอร์ตอื่น ในบทนี้เขียน URL เป็น http://localhost:3000 ให้ตรงกับที่คุณจะเห็นในเครื่องตัวเอง
เจอเรื่องหนึ่งระหว่างตรวจ: ในโปรเจกต์ใหม่ที่ยังไม่เคย build ถ้ารัน npx tsc --noEmit ก่อน จะได้ app/layout.tsx(20,50): error TS2304: Cannot find name 'LayoutProps'. — เพราะ type นั้น Next.js สร้างให้ตอน build/dev · รัน npx next build หนึ่งครั้งก่อน แล้ว error นี้หายเอง

1 · อ่านดีไซน์ให้ออก ก่อนเขียน type ของ props

สไลด์หน้า 42 คือภาพหน้า All Recipes ของจริง — การ์ดเรียงสี่คอลัมน์ · สไลด์หน้า 45 ซูมเข้าไปที่การ์ดใบเดียว แล้วไล่ว่าการ์ดหนึ่งใบต้องรู้อะไรบ้าง (ในสไลด์เรียกว่า DESIGN PROPERTIES):

แล้วสไลด์หน้า 46 ทำสิ่งที่สำคัญมาก — หัวข้อเปลี่ยนเป็น DESIGN PROPERTIES FOCUSED และทำตัวอักษรให้จางลงสามรายการ: owner image · Is favorite? · onFavorite
แปลว่า "รอบนี้ยังไม่ทำ" · ดีไซน์วาดไว้ครบ แต่งานรอบแรกเลือกทำเฉพาะที่จำเป็นก่อน

ทีนี้แปลงรายการนั้นเป็นตาราง โดยถามทีละช่องว่า "ถ้าช่องนี้ไม่มี การ์ดยังวาดได้ไหม?"

ในสไลด์ชื่อ fieldต้องมีไหมเหตุผล
Recipe namename: string✅ ต้องมีการ์ดไม่มีชื่อ = ไม่รู้ว่าเป็นเมนูอะไร
imageimageUrl?: string⭕ ไม่มีก็ได้ในภาพดีไซน์ยังไม่มีรูปจริงสักใบ ทุกใบเป็นลายทางเขียนว่า dish photo — … → ต้องมีภาพสำรอง
levellevel: RecipeLevel✅ ต้องมีป้าย Easy / Medium / Hard อยู่ทุกใบ · หัวข้อ 2 จะอธิบายว่าทำไมไม่ใช้ string
owner nameownerName: string✅ ต้องมีทุกสูตรมีคนเขียน
owner image—⏸ ยังไม่ทำสไลด์หน้า 46 ทำให้จาง (เก็บไว้เป็นภารกิจท้ายบท)
rating / rating countrating: number · ratingCount: number✅ ต้องมีการ์ดโชว์ ★ 4.9 (312) · สูตรใหม่ที่ยังไม่มีคนให้ดาวก็ยังมีค่า 0 ได้
Is favorite? / onFavorite—⏸ ยังไม่ทำสไลด์หน้า 46 ทำให้จาง · ต้องมีระบบ login ก่อนถึงจะรู้ว่า "ใคร" กดหัวใจ
onClickonClick?: (id: number) => void⭕ ไม่มีก็ได้การ์ดวาดได้โดยไม่ต้องกด · หน้าที่แค่โชว์ก็ไม่ต้องส่ง

สังเกตอีกเรื่อง: การ์ดในสไลด์หน้า 42 มีป้ายเวลา (25 min) อยู่มุมรูป แต่รายการในหน้า 45 ไม่มีเวลา และการ์ดตัวอย่างในหน้า 45 ก็ไม่มีป้ายนั้น · บทนี้ยึดรายการในหน้า 45–46 · ถ้าเจอแบบนี้ในงานจริง ให้ถามคนออกแบบ อย่าเดาเอง

เขียนตารางข้างบนออกมาเป็นโค้ด — ? หลังชื่อ field แปลว่า "ไม่มีก็ได้":

// components/RecipeCard/RecipeCard.tsx (ส่วน type)
export type RecipeLevel = "EASY" | "MEDIUM" | "HARD";

export type RecipeSummary = {
  id: number;
  name: string;
  imageUrl?: string; // ยังไม่มีรูปจริง → แสดง placeholder
  level: RecipeLevel;
  ownerName: string;
  rating: number;
  ratingCount: number;
};

export type RecipeCardProps = {
  recipe: RecipeSummary;
  onClick?: (id: number) => void;
};

ทำไมแยกเป็นสองชั้น (RecipeSummary อยู่ข้างใน RecipeCardProps)? เพราะโค้ดตัวอย่างในสไลด์หน้า 45 เรียกการ์ดแบบนี้ — ส่งสูตรทั้งก้อนเป็น prop เดียวชื่อ recipe:

// จากสไลด์หน้า 45 (ส่วนที่ยังใช้ในรอบนี้)
<RecipeCard
  recipe={recipe}
/>

ข้อมูลของสูตร (RecipeSummary) กับสิ่งที่การ์ดรับเพิ่ม (onClick) จึงแยกกันชัด · วันที่หน้าอื่นต้องใช้ข้อมูลสูตรแบบเดียวกัน ก็ import RecipeSummary ไปได้เลย โดยไม่ต้องลาก onClick ติดไปด้วย

ช่องที่ติดป้าย "ต้องมี" ต้องมีจริง — ผมลองลบ ratingCount ออกจากข้อมูลตัวอย่างหนึ่งแถว:

app/recipes/mock.ts(4,3): error TS2741: Property 'ratingCount' is missing in type
  '{ id: number; name: string; level: "EASY"; ownerName: string; rating: number; }'
  but required in type 'RecipeSummary'.

ส่วนรูปที่ยังไม่มี — การ์ดเช็คก่อนว่ามี imageUrl ไหม ถ้าไม่มีก็วาดกล่องสีเทาแทน เหมือนภาพ dish photo ในสไลด์:

// components/RecipeCard/RecipeCard.tsx (ส่วนที่เหลือ)
"use client";

import { Badge } from "@/components/ui/badge";
import { Card } from "@/components/ui/card";

// … type ทั้งสามตัวข้างบน …

const levelLabel: Record<RecipeLevel, string> = {
  EASY: "Easy",
  MEDIUM: "Medium",
  HARD: "Hard",
};

const RecipeCard = ({ recipe, onClick }: RecipeCardProps) => {
  return (
    <Card className="pt-0" onClick={() => onClick?.(recipe.id)}>
      {recipe.imageUrl ? (
        // eslint-disable-next-line @next/next/no-img-element
        <img src={recipe.imageUrl} alt={recipe.name} className="aspect-4/3 object-cover" />
      ) : (
        <div className="flex aspect-4/3 items-center justify-center bg-muted text-xs text-muted-foreground">
          dish photo — {recipe.name}
        </div>
      )}
      <div className="flex flex-col gap-2 px-4">
        <h3 className="line-clamp-2 font-medium">{recipe.name}</h3>
        <p className="text-muted-foreground">{recipe.ownerName}</p>
        <div className="flex items-center justify-between">
          <Badge variant="secondary">{levelLabel[recipe.level]}</Badge>
          <span>
            ★ {recipe.rating.toFixed(1)} ({recipe.ratingCount})
          </span>
        </div>
      </div>
    </Card>
  );
};

export default RecipeCard;

สามจุดที่อาจสะดุด:
· aspect-4/3 มาจากคำอธิบายในสไลด์หน้า 45 ที่เขียนว่ารูปเป็น 4:3 · line-clamp-2 มาจากประโยค title at two lines max ในหน้าเดียวกัน
· onClick?.(recipe.id) — ?. แปลว่า "ถ้ามี onClick ค่อยเรียก ถ้าไม่มีก็ข้ามไป" ไม่ error
· บรรทัด eslint-disable ปิดคำเตือนของ Next.js ที่อยากให้ใช้ <Image> แทน <img> — ตอนนี้ยังไม่มีรูปจริงให้ตั้งค่า เลยใช้ <img> ธรรมดาไปก่อน
ส่วน "use client" บรรทัดแรก มีเหตุผลเฉพาะ — หัวข้อ 4 จะรันให้ดูว่าเกิดอะไรขึ้นถ้าลืมคิดเรื่องนี้

🧰 เทียบให้เห็นภาพ — ใบสมัครที่มีช่องติดดาว

ใบสมัครงานมีช่องติดดาวแดง (ต้องกรอก) กับช่องไม่ติดดาว (กรอกหรือไม่ก็ได้) และบางทีมีทั้งหน้าที่เขียนว่า "สำหรับเจ้าหน้าที่ — ยังไม่ต้องกรอก"
คนออกแบบฟอร์มต้องตัดสินใจเรื่องนี้ก่อนพิมพ์ฟอร์มออกมา ไม่ใช่ไปตัดสินใจตอนคนมาสมัครแล้ว
RecipeCardProps ก็คือฟอร์มนั้น — ช่องไม่มี ? คือช่องติดดาว · ช่องมี ? คือกรอกหรือไม่ก็ได้ · ส่วนรายการที่สไลด์หน้า 46 ทำให้จาง คือหน้า "ยังไม่ต้องกรอก" ที่เรายังไม่ใส่ลงฟอร์มเลย

ที่ไม่เหมือนกัน: ใบสมัครจริงมีเจ้าหน้าที่ตรวจตอนคนมายื่น · แต่ TypeScript ตรวจตอนเขียนโค้ด — มันเห็นแค่ข้อมูลที่พิมพ์ไว้ในไฟล์ · วันที่ข้อมูลมาจาก API จริง ถ้า backend ส่ง JSON ที่ไม่มี ratingCount มา TypeScript ไม่มีทางรู้ เพราะ type หายไปตอนรันแล้ว (ตามที่บทเรียน 63 พิสูจน์ไว้)

🗣 ศัพท์ที่เพิ่งเรียนไป
design properties
รายการ "ข้อมูลที่ component ต้องรู้" ที่อ่านออกมาจากภาพดีไซน์ · เป็นขั้นก่อนเขียน props type · พิมพ์บอก AI ได้ว่า “list the design properties of this card, then mark each as required, optional, or out of scope” จะได้ตารางแบบในหัวข้อนี้ แทนที่จะได้โค้ดยาวทันที
required / optional field
field ที่ไม่มี ? = ต้องส่ง ไม่ส่งได้ TS2741 · field ที่มี ? = ไม่ส่งก็ได้ ค่าจะเป็น undefined · ถาม senior ได้ว่า “field นี้ API รับประกันว่ามีเสมอไหม หรือควรทำเป็น optional” — คำตอบต้องมาจากสัญญาของ backend ไม่ใช่จากภาพดีไซน์อย่างเดียว
scope / focus (ของรอบงาน)
การเลือกว่ารอบนี้ทำอะไร และไม่ทำอะไร — สิ่งที่สไลด์หน้า 46 เรียกว่า FOCUSED · พูดในทีมได้ว่า “favorite อยู่นอก scope ของ ticket นี้ เดี๋ยวแยก ticket ใหม่” ดีกว่าแอบทำเพิ่มเงียบ ๆ แล้ว PR ใหญ่จนไม่มีใครรีวิวไหว
placeholder
ของที่วางไว้แทนของจริงที่ยังไม่มา เช่นกล่องเทาเขียนว่า dish photo · พิมพ์บอก AI ได้ว่า “render a 4:3 placeholder when imageUrl is missing”

สไลด์หน้า 46 (DESIGN PROPERTIES FOCUSED) ทำตัวอักษรให้จางไว้บางรายการ — ชุดไหนคือรายการที่รอบนี้ยังไม่ต้องใส่ลงใน props?

ตัวเลือกที่ 4 ถูก · ในสไลด์หน้า 46 สามรายการนี้เป็นตัวจาง · favorite ต้องรู้ว่าผู้ใช้คนไหนกด ส่วนรูปคนเขียนยังไม่มีข้อมูล จึงพักไว้

ตัวเลือกที่ 1 ผิด เพราะ rating, rating count และ onClick ยังเป็นตัวเข้มในหน้า 46 · ตัวเลือกที่ 2 ผิด เพราะ image กับ level อยู่ในบรรทัดแรกที่เข้มทั้งบรรทัด (รูปที่ยังไม่มีแก้ด้วย placeholder ไม่ใช่ตัดทิ้ง) · ตัวเลือกที่ 3 ผิด เพราะชื่อสูตรกับชื่อคนเขียนคือหัวใจของการ์ด และ "มาจาก API" ไม่ใช่เหตุผลให้ตัดออกจาก props
จุดที่ต้องจำ: ดีไซน์วาดครบไม่ได้แปลว่าต้องทำครบในรอบเดียว — เลือกสิ่งที่ focus ก่อน แล้วค่อยเพิ่มทีละช่อง

2 · กับดัก: level เป็น string อิสระ หรือ union ของสามคำ

ป้ายระดับความยากในการ์ดมีได้แค่สามค่า — Easy · Medium · Hard · แต่ท่าที่มือใหม่เขียนบ่อยที่สุดคือ level: string เพราะ "มันก็เป็นข้อความนี่"
ลองดูว่าถ้าเขียนแบบนั้น แล้วพิมพ์ผิดหนึ่งตัวในข้อมูล จะเกิดอะไรขึ้น

// เปลี่ยน type เป็น string (และ levelLabel ต้องเปลี่ยนตาม ไม่งั้นได้ TS7053)
export type RecipeSummary = {
  // …
  level: string;
};
const levelLabel: Record<string, string> = { EASY: "Easy", MEDIUM: "Medium", HARD: "Hard" };

// app/recipes/mock.ts — พิมพ์ Y เกินมาหนึ่งตัว
{ id: 1, name: "Thai basil chicken with a crisp fried egg", level: "EASYY", /* … */ },

npx tsc --noEmit จะฟ้องไหม? แล้วป้าย level ของการ์ดใบแรกใน HTML จะเขียนว่าอะไร?

tsc ไม่ฟ้องเลย · exit code = 0 · และ HTML ที่ curl ได้จริง (ตัด class ออก):

$ curl -s http://localhost:3000/recipes
<h3>Thai basil chicken with a crisp fried egg</h3><p>Ornuma K.</p>
<div><span data-slot="badge" data-variant="secondary"></span><span>★ <!-- -->4.9<!-- --> (<!-- -->312<!-- -->)</span></div>
…
<span data-slot="badge" data-variant="secondary">Medium</span>
…
<span data-slot="badge" data-variant="secondary">Hard</span>

ป้ายของใบแรกว่างเปล่า — ไม่ใช่ "EASYY" และไม่ใช่ error · เพราะ levelLabel["EASYY"] หาคีย์ไม่เจอ ได้ undefined และ React วาด undefined เป็นความว่างเปล่า · ผู้ใช้เห็นป้ายเปล่า ๆ หนึ่งอัน ไม่มีใครรู้ว่าพังตรงไหน · (<!-- --> คือตัวคั่นข้อความที่ React ใส่เอง ไม่ใช่บั๊ก)

ทีนี้กลับไปใช้ type แบบในหัวข้อ 1 — level: RecipeLevel ซึ่งคือ "EASY" | "MEDIUM" | "HARD" — แล้วใส่ข้อมูลที่พิมพ์ผิดตัวเดิม:

app/recipes/mock.ts(4,63): error TS2820: Type '"EASYY"' is not assignable to type 'RecipeLevel'.
  Did you mean '"EASY"'?

ฟ้องทันที พร้อมเดาให้ด้วยว่าตั้งใจพิมพ์ "EASY" · และถ้าใครไปเปลี่ยน level กลับเป็น string แต่ลืมแก้ levelLabel ก็ติดอีกด่าน:

components/RecipeCard/RecipeCard.tsx(44,39): error TS7053: Element implicitly has an 'any' type
  because expression of type 'string' can't be used to index type 'Record<RecipeLevel, string>'.
  No index signature with a parameter of type 'string' was found on type 'Record<RecipeLevel, string>'.
type ของ levelข้อมูลพิมพ์ "EASYY"ผลจริงบนหน้าเว็บ
string✅ tsc ผ่าน (exit 0)☠️ ป้ายว่างเปล่า ไม่มีใครเตือน
"EASY" | "MEDIUM" | "HARD"❌ TS2820 พร้อม "Did you mean"✅ ไม่มีทางไปถึงหน้าเว็บ
🧰 เทียบให้เห็นภาพ — ช่องกรอกอิสระ vs ช่องติ๊กเลือก

แบบสอบถามถามว่า "ระดับความเผ็ด" · ถ้าให้เขียนเอง จะมีคนเขียน "เผ็ดมก" "ผ็ดน้อย" "กลางๆ" แล้วคนนับผลก็ไม่รู้จะนับเป็นช่องไหน — กระดาษรับทุกคำโดยไม่บ่น · ถ้าเปลี่ยนเป็นช่องติ๊ก ☐ น้อย ☐ กลาง ☐ มาก ก็ไม่มีทางกรอกคำแปลก ๆ ได้ตั้งแต่แรก
level: string คือช่องเขียนอิสระ · "EASY" | "MEDIUM" | "HARD" คือช่องติ๊กสามช่อง

ที่ไม่เหมือนกัน: ช่องติ๊กบนกระดาษกันได้ทุกคนที่กรอก · แต่ union กันได้เฉพาะค่าที่พิมพ์อยู่ในโค้ดตอน tsc ตรวจ · ถ้า API ส่ง "EASYY" มาตอนรันจริง TypeScript มองไม่เห็น ป้ายก็ยังว่างได้เหมือนเดิม

🗣 ศัพท์ที่เพิ่งเรียนไป
string literal union
type ที่รับได้แค่ข้อความไม่กี่คำที่ระบุไว้ เช่น "EASY" | "MEDIUM" | "HARD" · พิมพ์ผิดได้ TS2820 พร้อม Did you mean · พิมพ์บอก AI ได้ว่า “type level as a string literal union, not a plain string”
display label (แยกจากค่าที่เก็บ)
ข้อความที่คนเห็น (Easy) แยกจากค่าในข้อมูล (EASY) ด้วย object อย่าง levelLabel · ถาม senior ได้ว่า “ค่า level ที่ API ส่งมาเป็นตัวพิมพ์แบบไหน แล้ว label ภาษาไทยควรอยู่ฝั่งไหน”

ทำไมเป็นตัวใหญ่ "EASY" ไม่ใช่ "Easy" ตามที่การ์ดโชว์? ผมแยก "ค่าที่เก็บ" ออกจาก "ข้อความที่โชว์" ด้วย levelLabel · ค่าที่เก็บตั้งให้หน้าตาเหมือน enum ที่ backend มักส่งมา ส่วนคำที่คนอ่านเปลี่ยนได้ทีหลัง (เช่นเป็นภาษาไทย) โดยไม่แตะข้อมูล · สไลด์ไม่ได้กำหนดเรื่องนี้ — ในโปรเจกต์จริงให้ยึดค่าที่ API ของทีมส่งมาจริง

ถ้าประกาศ level: string แล้วข้อมูลสูตรหนึ่งพิมพ์ว่า "MEDUIM" (สลับตัวอักษร) — จะเกิดอะไรขึ้น?

ตัวเลือกที่ 3 ถูก · string รับข้อความอะไรก็ได้ tsc จึงไม่มีอะไรให้ฟ้อง · พอถึงตอนวาด levelLabel["MEDUIM"] ได้ undefined ซึ่ง React วาดเป็นความว่างเปล่า

ตัวเลือกที่ 1 ผิด เพราะ TS2820 เกิดเฉพาะตอนใช้ union "EASY" | "MEDIUM" | "HARD" · ตัวเลือกที่ 2 ผิด เพราะการอ่านคีย์ที่ไม่มีใน object ไม่ใช่ error ใน JavaScript · ตัวเลือกที่ 4 ผิด เพราะการ์ดไม่ได้โชว์ level ตรง ๆ แต่โชว์ค่าที่ค้นจาก levelLabel
จุดที่ต้องจำ: ค่าที่มีได้ไม่กี่แบบ ให้ประกาศเป็น union ของคำเหล่านั้น — พิมพ์ผิดแล้ว tsc จับให้ ไม่ต้องรอผู้ใช้เจอ

3 · หน้าเดียวมี 4 สถานะ — มือใหม่มักทำแค่สถานะแรก

กลับไปดูมุมขวาบนของสไลด์หน้า 42 — มีปุ่มสลับเขียนว่า STATE: Default · Loading · Empty · Error และแถบซ้ายของสไลด์ไล่รายการเดียวกันไว้
หน้า 42 โชว์สถานะ Default (มีการ์ดเต็มหน้า) · หน้า 43 กดไปที่ Empty — ได้ไอคอนแว่นขยาย ข้อความ No recipes match your search และปุ่ม Clear filters พร้อมบรรทัดใต้หัวข้อที่เปลี่ยนเป็น 0 recipes found และประโยคแนะนำให้ลองค้นด้วยชื่อที่สั้นลง · (สไลด์ไม่ได้โชว์ภาพของ Loading กับ Error)

ประเด็นคือคนออกแบบวาดไว้ครบสี่แบบ — เพราะผู้ใช้จริงเจอครบสี่แบบ:

สถานะเกิดตอนไหนถ้าไม่ได้ทำไว้ ผู้ใช้เห็นอะไร
Defaultข้อมูลมาครบ มีอย่างน้อยหนึ่งสูตร— (อันนี้ทุกคนทำ)
Loadingกำลังรอ API ตอบ · เน็ตช้าหน้าขาว ๆ ไม่รู้ว่าเว็บค้างหรือกำลังโหลด
EmptyAPI ตอบสำเร็จ แต่ได้ลิสต์ว่าง · ค้นหาแล้วไม่เจอหน้าขาวเหมือนกัน — แยกไม่ออกจาก Loading
ErrorAPI ล่ม · เน็ตหลุดหน้าขาว หรือหน้าพังทั้งหน้า

ท่าที่เขียนกันบ่อยคือเก็บสถานะเป็นตัวแปรแยกหลายตัว (isLoading · error · recipes) · ปัญหาคือมันยอมให้เกิดสถานะที่เป็นไปไม่ได้ — กำลังโหลด และ error และ มีข้อมูลพร้อมกัน · ผมเขียนสองแบบในไฟล์เดียวกันแล้วรัน tsc:

ตัวแปรแยก — tsc ไม่ว่าอะไร

type FlagState = {
  isLoading: boolean;
  error?: string;
  recipes: RecipeSummary[];
};
const flags: FlagState = {
  isLoading: true,
  error: "Could not load recipes",
  recipes: mockRecipes,
};

ผ่าน — แล้วหน้าเว็บควรโชว์อะไร? สปินเนอร์ ข้อความ error หรือการ์ด? ไม่มีใครตอบได้

union ที่มีป้าย status — tsc ตีกลับ

const union: RecipeListState = {
  status: "loading",
  recipes: mockRecipes,
};
error TS2353: Object literal may only specify
  known properties, and 'recipes' does not exist
  in type '{ status: "loading"; }'.

สถานะ "loading" ไม่มีช่องให้ใส่ข้อมูลเลย — เขียนผิดไม่ได้ตั้งแต่แรก

RecipeListState หน้าตาแบบนี้ — union สามแบบ แต่ละแบบมีช่อง status เป็นป้ายบอกว่าตัวเองคือแบบไหน และมีเฉพาะช่องที่สถานะนั้นต้องใช้:

// components/RecipeList/RecipeList.tsx
import Link from "next/link";
import { RecipeCard, type RecipeSummary } from "@/components/RecipeCard";
import { Skeleton } from "@/components/ui/skeleton";

export type RecipeListState =
  | { status: "loading" }
  | { status: "error"; message: string }
  | { status: "success"; recipes: RecipeSummary[] };

const RecipeList = ({ state }: { state: RecipeListState }) => {
  if (state.status === "loading") {
    return (
      <div className="grid grid-cols-4 gap-4" aria-busy="true">
        {Array.from({ length: 4 }, (_, i) => (
          <Skeleton key={i} className="aspect-3/4" />
        ))}
      </div>
    );
  }

  if (state.status === "error") {
    return (
      <div role="alert" className="py-16 text-center">
        <h3 className="font-medium">Something went wrong</h3>
        <p className="text-muted-foreground">{state.message}</p>
        <Link href="/recipes">Try again</Link>
      </div>
    );
  }

  // มาถึงบรรทัดนี้ได้ = status ต้องเป็น "success" แน่นอน → state.recipes ใช้ได้
  if (state.recipes.length === 0) {
    return (
      <div className="py-16 text-center">
        <h3 className="font-medium">No recipes match your search</h3>
        <Link href="/recipes">Clear filters</Link>
      </div>
    );
  }

  return (
    <div className="grid grid-cols-4 gap-4">
      {state.recipes.map((recipe) => (
        <RecipeCard key={recipe.id} recipe={recipe} />
      ))}
    </div>
  );
};

export default RecipeList;

สังเกตว่าไม่มี status: "empty" · "ว่าง" คือ "โหลดสำเร็จแต่ได้ลิสต์ยาว 0" — ถ้าแยกเป็นสถานะที่สี่ จะเขียน { status: "success", recipes: [] } ได้อยู่ดี แล้วต้องมานั่งเถียงว่าอันไหนถูก · ให้ความยาวของลิสต์เป็นตัวตัดสินที่เดียวดีกว่า
และ Skeleton ของ shadcn เปิดไฟล์ดูแล้วเป็นแค่ <div> ที่มี class animate-pulse rounded-md bg-muted — กล่องสีเทากะพริบเบา ๆ ที่กินที่เท่ากับของจริง พอข้อมูลมาหน้าจะไม่กระตุก

สุดท้ายคือหน้าที่เรียกใช้ · เพื่อให้สลับดูทั้ง 4 สถานะได้เหมือนปุ่ม STATE ในสไลด์ ผมให้หน้าอ่านค่า ?state= จาก URL (ข้อมูลยังเป็นของปลอม — ต่อ API จริงเป็นเรื่องของบทหลัง):

// app/recipes/page.tsx
import { RecipeList, type RecipeListState } from "@/components/RecipeList";
import { mockRecipes } from "./mock";

const pickState = (state?: string): RecipeListState => {
  if (state === "loading") return { status: "loading" };
  if (state === "error") return { status: "error", message: "Could not load recipes" };
  if (state === "empty") return { status: "success", recipes: [] };
  return { status: "success", recipes: mockRecipes };
};

export default async function RecipesPage({
  searchParams,
}: {
  searchParams: Promise<{ state?: string }>;
}) {
  const { state } = await searchParams;
  return (
    <main className="mx-auto max-w-5xl p-8">
      <h1 className="text-2xl font-bold">All Recipes</h1>
      <RecipeList state={pickState(state)} />
    </main>
  );
}

(index.ts ของ components/RecipeCard/ และ components/RecipeList/ เป็น barrel แบบเดียวกับบทเรียน 71 หัวข้อ 4 · app/recipes/mock.ts คือ array ของ RecipeSummary สามสูตรแรกในสไลด์หน้า 45)

ผลรันจริง — npx next build แล้ว npx next start แล้ว curl ทีละสถานะ (ตัด class ออก):

$ curl -s "http://localhost:3000/recipes"                  # Default
<main><h1>All Recipes</h1><div><div data-slot="card" data-size="default"><div>dish photo — <!-- -->Thai basil chicken with a crisp fried egg</div><div><h3>Thai basil chicken with a crisp fried egg</h3><p>Ornuma K.</p><div><span data-slot="badge" data-variant="secondary">Easy</span><span>★ <!-- -->4.9<!-- --> (<!-- -->312<!-- -->)</span></div></div></div>
  … การ์ดอีกสองใบ (Medium · Hard) โครงเดียวกัน …
</div></main>

$ curl -s "http://localhost:3000/recipes?state=empty"      # Empty
<main><h1>All Recipes</h1><div><h3>No recipes match your search</h3><a href="/recipes">Clear filters</a></div></main>

$ curl -s "http://localhost:3000/recipes?state=loading"    # Loading
<main><h1>All Recipes</h1><div aria-busy="true"><div data-slot="skeleton"></div><div data-slot="skeleton"></div><div data-slot="skeleton"></div><div data-slot="skeleton"></div></div></main>

$ curl -s "http://localhost:3000/recipes?state=error"      # Error
<main><h1>All Recipes</h1><div role="alert"><h3>Something went wrong</h3><p>Could not load recipes</p><a href="/recipes">Try again</a></div></main>

นับจาก HTML ที่ได้: Default มี data-slot="card" 3 ใบ · Loading มี data-slot="skeleton" 4 กล่อง และการ์ด 0 ใบ · Empty กับ Error ไม่มีทั้งการ์ดและ skeleton — component ตัวเดียว ได้ HTML สี่แบบที่ไม่ซ้ำกันเลย

🧰 เทียบให้เห็นภาพ — จอบอกสถานะคิวที่ร้านอาหาร

ร้านที่ดีมีจอหน้าร้านที่ขึ้นได้ทีละข้อความเดียว: "กำลังเตรียมอาหาร" · "พร้อมเสิร์ฟ — โต๊ะ 12" · "วันนี้เมนูนี้หมดแล้ว" · "ระบบครัวขัดข้อง ขออภัย"
ลูกค้าไม่เคยเห็นจอขึ้นว่า "กำลังเตรียม" และ "พร้อมเสิร์ฟ" และ "ครัวขัดข้อง" พร้อมกัน · และจอไม่เคยดับเฉย ๆ ให้ลูกค้ายืนเดาว่าร้านลืมออร์เดอร์หรือเปล่า
status ในบทนี้คือจอนั้น — ค่าเดียว บอกชัดว่าตอนนี้อยู่สถานะไหน และแต่ละสถานะมีข้อความของตัวเอง

ที่ไม่เหมือนกัน: จอร้านมีคนกดเปลี่ยนข้อความเอง ถ้าเขาลืมกด จอก็ค้างผิด ๆ ได้ · แต่ union ใน TypeScript ไม่ได้รับประกันว่าค่าจะเปลี่ยนถูกจังหวะ — มันรับประกันแค่ว่ารูปร่างของแต่ละสถานะถูก (loading ไม่มี recipes, error ต้องมี message) · ส่วน "เปลี่ยนเป็น success ตอน API ตอบ" ยังเป็นหน้าที่ของโค้ดที่คุณเขียนเอง

🗣 ศัพท์ที่เพิ่งเรียนไป
UI states (loading / empty / error)
สถานะของหน้าที่ต้องออกแบบนอกเหนือจากตอนมีข้อมูล · ถาม designer ได้ว่า “มีดีไซน์ของ empty state กับ error state ของหน้านี้ไหม” — ถ้าไม่มี ให้ขอก่อนเริ่มเขียน อย่าไปคิดหน้าตาเองตอนใกล้ส่งงาน
discriminated union
union ของ object หลายแบบที่ทุกแบบมีช่องเดียวกัน (ที่นี่คือ status) เป็นค่าตายตัวคนละคำ ใช้เป็นป้ายแยกว่าเป็นแบบไหน · ต่อยอดจาก union ในบทเรียน 64 · พิมพ์บอก AI ได้ว่า “model this page state as a discriminated union on status, not separate booleans”
narrowing
พอเขียน if (state.status === "error") TypeScript รู้ทันทีว่าในวงเล็บนั้น state มี message และหลังจาก return ไปแล้ว มันตัดแบบนั้นทิ้งจากความเป็นไปได้ · (เจอคำนี้มาแล้วในบทเรียน 64 และบทเรียน 65)
impossible state
ค่าที่ type ยอมให้เขียน แต่ไม่มีความหมายในโลกจริง เช่น isLoading: true พร้อม error · ในรีวิวโค้ดพูดได้ว่า “type นี้ทำให้เกิด impossible state ได้ ลองเปลี่ยนเป็น union ไหม”
skeleton
กล่องเทากะพริบที่วาดแทนเนื้อหาระหว่างโหลด ขนาดเท่าของจริง · ใน shadcn คือ <Skeleton className="…"> ที่คุณกำหนดขนาดเองด้วย Tailwind

ประโยชน์ที่ใหญ่ที่สุดของ union คือตอนเพิ่มสถานะใหม่ · สมมติวันหนึ่งมีคนเพิ่ม | { status: "idle" } (ยังไม่เริ่มค้นหา) แต่ลืมเขียน if รองรับ:

components/RecipeList/RecipeList.tsx(33,13): error TS2339: Property 'recipes' does not exist on type
  '{ status: "idle"; } | { status: "success"; recipes: RecipeSummary[]; }'.
  Property 'recipes' does not exist on type '{ status: "idle"; }'.
components/RecipeList/RecipeList.tsx(44,14): error TS2339: Property 'recipes' does not exist on type
  '{ status: "idle"; } | { status: "success"; recipes: RecipeSummary[]; }'.
  Property 'recipes' does not exist on type '{ status: "idle"; }'.
components/RecipeList/RecipeList.tsx(44,27): error TS7006: Parameter 'recipe' implicitly has an 'any' type.

TypeScript ชี้ตรงบรรทัดที่ยังคิดว่า "มาถึงตรงนี้ต้องเป็น success แน่" · ถ้าใช้ตัวแปรแยกแบบ isLoading / error จะไม่มีอะไรฟ้องเลย

แต่ union ก็ไม่ได้กันทุกอย่าง — มีความผิดพลาดแบบหนึ่งที่มันมองไม่เห็น:

// ลบก้อน if ของ Empty ทิ้งทั้งก้อน (9 บรรทัด) ไม่แตะอย่างอื่น
  if (state.status === "error") { /* … เหมือนเดิม … */ }

  // ❌ ก้อน if (state.recipes.length === 0) หายไป

  return (
    <div className="grid grid-cols-4 gap-4">
      {state.recipes.map((recipe) => ( /* … */ ))}
    </div>
  );

tsc จะฟ้องไหม? แล้ว /recipes?state=empty จะได้ HTML หน้าตาแบบไหน?

tsc ผ่าน · exit code = 0 · HTML จริงจาก npx next dev (ก้อนนี้ไม่ได้ตัด class):

$ curl -s "http://localhost:3000/recipes?state=empty"
<main class="mx-auto max-w-5xl p-8"><h1 class="text-2xl font-bold">All Recipes</h1><div class="grid grid-cols-4 gap-4"></div></main>

ได้ grid เปล่า ๆ — หัวข้อ All Recipes แล้วข้างล่างขาวโล่ง · tsc ไม่ฟ้องเพราะ recipes: [] เป็น RecipeSummary[] ที่ถูกต้องสมบูรณ์ · "ลิสต์ว่าง" ไม่ใช่เรื่องของ type แต่เป็นเรื่องของค่า · เพราะฉะนั้น Empty คือสถานะที่ลืมง่ายที่สุดในสี่แบบ — ต้องเปิดดูด้วยข้อมูลว่างจริงทุกครั้ง

type แบบไหนเขียน { status: "loading", recipes: mockRecipes } ไม่ผ่าน tsc?

ตัวเลือกที่ 1 ถูก · ในแบบนี้ object ที่ status เป็น "loading" มีรูปร่างเดียวคือ { status: "loading" } พอใส่ recipes เพิ่มก็ได้ TS2353 ตามที่รันให้ดูในหัวข้อนี้

ตัวเลือกที่ 2 และ 4 ผิด เพราะเป็น object ก้อนเดียวที่ recipes เป็น optional — จะมีหรือไม่มีก็ได้ทุกสถานะ (ตัวเลือกที่ 4 จับ typo ของ status ได้ แต่ยังใส่ recipes คู่กับ loading ได้) · ตัวเลือกที่ 3 ผิด เพราะคือแบบตัวแปรแยกที่ tsc ยอมให้ isLoading: true อยู่กับข้อมูลได้
จุดที่ต้องจำ: แยกสถานะเป็นหลาย object ใน union ที่แต่ละแบบมีเฉพาะช่องของตัวเอง — แล้วสถานะที่เป็นไปไม่ได้จะเขียนไม่ได้ตั้งแต่แรก

4 · ทำไม RecipeCard ต้องมี "use client" — และกับดักตอนส่ง onClick

RecipeCard มี onClick ซึ่งคือการตอบสนองต่อการกดของผู้ใช้ — เกิดขึ้นในเบราว์เซอร์ · มันจึงต้องเป็น Client Component (บรรทัด "use client") เหมือน component ที่ใช้ hook ในบทเรียน 73
ส่วน RecipeList กับ page.tsx ไม่มี "use client" — พวกมันรันบน server

ทีนี้ ถ้าอยากให้การ์ดกดได้ ท่าที่มือใหม่เขียนเป็นอย่างแรกคือส่งฟังก์ชันจาก RecipeList ลงไปตรง ๆ:

// components/RecipeList/RecipeList.tsx — ไฟล์นี้ไม่มี "use client"
<RecipeCard key={recipe.id} recipe={recipe} onClick={(id) => console.log(id)} />

⚠️ tsc ผ่าน · build ผ่าน · แต่เปิดหน้าแล้วได้ 500

ผมรันจริงครบสามด่าน:

$ npx tsc --noEmit          # exit 0 — ไม่ฟ้อง
$ npx next build            # ✓ Compiled successfully · └ ƒ /recipes
$ curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000/recipes
500

และใน terminal ที่รัน npx next dev:

⨯ Error: Event handlers cannot be passed to Client Component props.
  <... recipe={{...}} onClick={function onClick}>
                              ^^^^^^^^^^^^^^^^^^
If you need interactivity, consider converting part of this to a Client Component.

สาเหตุ: RecipeList รันบน server แต่ RecipeCard รันในเบราว์เซอร์ · ของที่ส่งข้ามจาก server ไปเบราว์เซอร์ต้องแปลงเป็นข้อความได้ (ตัวเลข ข้อความ object ธรรมดา) แต่ฟังก์ชันส่งข้ามไม่ได้ · TypeScript ไม่รู้เรื่องเส้นแบ่ง server/เบราว์เซอร์ จึงไม่ฟ้อง · ทางแก้คือให้ component ที่ "ถือ" ฟังก์ชันเป็น Client Component ด้วย — บทนี้ยังไม่ส่ง onClick จริง (โค้ดในหัวข้อ 3 จึงผ่านทุกด่าน) ส่วนคำถามว่า container ที่ map การ์ดต้องใส่ "use client" ไหม บทเรียน 85 build วัดให้ดูทั้งสองแบบ

⚠️ โค้ดตัวอย่างในสไลด์หน้า 45 อย่าก็อปบรรทัดที่สามไปตรง ๆ — sc-camel-on-toggle-favorite={…} ดูเป็นชื่อที่เครื่องมือออกแบบสร้างขึ้น ไม่ใช่ชื่อ prop ที่ React ใช้กัน (ธรรมเนียมคือ camelCase เช่น onToggleFavorite) · ที่น่ากลัวคือผมลองใส่ sc-camel-on-toggle-favorite="x" ให้ RecipeCard ซึ่งไม่มี prop นี้ใน type เลย — npx tsc --noEmit ผ่าน exit 0 · เพราะ TypeScript ไม่ตรวจชื่อ prop ที่มีขีด - (เปิดทางไว้ให้ data-* / aria-*) · เทียบกับใส่ favorited (ไม่มีขีด) ซึ่งโดน TS2322 ทันที: Property 'favorited' does not exist on type 'IntrinsicAttributes & RecipeCardProps'.

🧰 เทียบให้เห็นภาพ — ส่งของข้ามประเทศทางไปรษณีย์

ครัวกลาง (server) แพ็กของส่งไปหน้าร้าน (เบราว์เซอร์) · ของที่ใส่กล่องได้คือเอกสาร ตัวเลข รายการสูตร · แต่ "พ่อครัวที่ยืนผัดอยู่" ใส่กล่องส่งไม่ได้ — ต้องจ้างพ่อครัวประจำหน้าร้านเอง
recipe คือเอกสารในกล่อง ส่งได้ · onClick คือพ่อครัว ส่งข้ามไม่ได้ · เลยต้องให้ component ฝั่งเบราว์เซอร์ ("use client") เป็นคนเขียนฟังก์ชันนั้นเอง

ที่ไม่เหมือนกัน: ไปรษณีย์จริงตีกล่องกลับตั้งแต่ตอนรับของ · แต่ Next.js ไม่ตีกลับตอน tsc หรือตอน build ของหน้า dynamic — มันไปพังตอนมีคนเปิดหน้า (500) ตามที่รันให้ดูข้างบน

🗣 ศัพท์ที่เพิ่งเรียนไป
Server Component / Client Component
ใน App Router ทุกไฟล์เป็น Server Component เว้นแต่มี "use client" · อันที่ต้องกด ต้องจำค่า หรือใช้ hook ต้องเป็น Client · ถาม senior ได้ว่า “เส้นแบ่ง client ของหน้านี้ควรอยู่ที่ component ไหน” — ยิ่งดันลงไปที่ใบเล็กสุดได้ ยิ่งส่ง JavaScript ไปเบราว์เซอร์น้อย
serializable props
props ที่แปลงเป็นข้อความส่งข้ามเครือข่ายได้ (ตัวเลข ข้อความ array object ธรรมดา) · ฟังก์ชันไม่ใช่ · พิมพ์บอก AI ได้ว่า “this Server Component passes a function prop to a Client Component — restructure it” พร้อมแปะ error ข้างบนไปด้วย

RecipeList.tsx (ไม่มี "use client") ส่ง onClick={() => …} ให้ RecipeCard ที่มี "use client" — ผลจริงคืออะไร?

ตัวเลือกที่ 2 ถูก · ผลรันจริงในหัวข้อ 4: tsc exit 0 · build ขึ้น ✓ Compiled successfully · curl ได้ 500 และ log ของ server ขึ้น Event handlers cannot be passed to Client Component props.

ตัวเลือกที่ 1 ผิด เพราะ type ของ onClick ถูกต้องทุกอย่าง TypeScript ไม่รู้ว่าไฟล์ไหนรันที่ไหน · ตัวเลือกที่ 3 ผิด เพราะหน้า /recipes อ่าน searchParams จึงเป็นหน้า dynamic (ƒ) ที่ถูกวาดตอนมีคนขอ ไม่ใช่ตอน build · ตัวเลือกที่ 4 ผิด เพราะฟังก์ชันส่งข้ามจาก server ไปเบราว์เซอร์ไม่ได้
จุดที่ต้องจำ: build ผ่านไม่ได้แปลว่าหน้าเปิดได้ — หน้าที่ dynamic ต้อง curl หรือเปิดดูจริงทุกครั้ง

5 · ภารกิจ — เพิ่ม owner image ที่สไลด์พักไว้

🎯 ภารกิจ

สไลด์หน้า 46 พัก owner image ไว้ ตอนนี้ถึงเวลาเพิ่ม — แต่ข้อมูลส่วนใหญ่ยังไม่มีรูปคนเขียน (การ์ดในสไลด์หน้า 45 เลยโชว์วงกลมตัวย่อชื่อแทน เช่น NK ของ Nok Charoen)

  1. เพิ่ม field รูปคนเขียนลงใน RecipeSummary — ตัดสินใจเองว่าต้องมีหรือไม่มีก็ได้
  2. ถ้ามีรูป ให้วาด <img> วงกลม · ถ้าไม่มี ให้วาดวงกลมที่มีตัวย่อชื่อสองตัวอักษร (ตัวใหญ่)
  3. ใส่รูปให้สูตรที่สามใน mock.ts หนึ่งสูตร อีกสองสูตรไม่ต้องใส่
  4. พิสูจน์: npx tsc --noEmit · npx eslint . · npx next build ผ่านทั้งสาม แล้ว curl /recipes ให้เห็นว่ามีตัวย่อ 2 อัน และ <img> ของคนเขียน 1 อัน

ownerImageUrl ต้องเป็น optional — ข้อ 3 บอกอยู่แล้วว่าสองสูตรไม่มีรูป ถ้าทำเป็นช่องบังคับ mock.ts จะได้ TS2741 สองแถว:

// components/RecipeCard/RecipeCard.tsx — ส่วนที่เพิ่ม
export type RecipeSummary = {
  // …
  ownerName: string;
  ownerImageUrl?: string;
  // …
};

const initials = (name: string) =>
  name
    .split(" ")
    .map((word) => word[0])
    .join("")
    .slice(0, 2)
    .toUpperCase();

// แทนบรรทัด <p className="text-muted-foreground">{recipe.ownerName}</p> ด้วย:
<div className="flex items-center gap-2">
  {recipe.ownerImageUrl ? (
    // eslint-disable-next-line @next/next/no-img-element
    <img src={recipe.ownerImageUrl} alt="" className="size-6 rounded-full" />
  ) : (
    <span className="flex size-6 items-center justify-center rounded-full bg-muted text-xs">
      {initials(recipe.ownerName)}
    </span>
  )}
  <span className="text-muted-foreground">{recipe.ownerName}</span>
</div>

// app/recipes/mock.ts — สูตรที่สาม
{ id: 3, name: "Grandmother's coconut pandan custard", level: "HARD", ownerName: "June P.", ownerImageUrl: "/june.png", rating: 4.8, ratingCount: 96 },

ผลรันจริง — tsc exit 0 · eslint exit 0 · build ✓ Compiled successfully · แล้ว curl (ตัด class ออก ตัดเหลือเฉพาะแถวคนเขียน):

$ curl -s http://localhost:3000/recipes
<h3>Thai basil chicken with a crisp fried egg</h3><div><span>OK</span><span>Ornuma K.</span></div>
<h3>Slow-simmered massaman beef curry</h3><div><span>NC</span><span>Nok Charoen</span></div>
<h3>Grandmother&#x27;s coconut pandan custard</h3><div><img src="/june.png" alt=""/><span>June P.</span></div>

ตัวย่อ 2 อัน (OK · NC) และรูป 1 อัน ครบตามโจทย์ · สังเกตว่าได้ NC แต่สไลด์หน้า 45 เขียน NK — ดีไซน์ใช้กฎย่อชื่อคนละแบบกับฟังก์ชันนี้ · กฎการย่อชื่อเป็นเรื่องที่ต้องถามคนออกแบบ ไม่ใช่เรื่องที่ type ตอบให้ได้ · alt="" ตั้งใจเว้นว่าง เพราะชื่อคนเขียนอยู่ข้าง ๆ แล้ว screen reader ไม่ต้องอ่านซ้ำ

6 · สรุปบทนี้

เรื่องจำแค่นี้พอ
ดีไซน์ → props ไล่ design properties ทีละช่อง ถามว่า "ไม่มีช่องนี้การ์ดยังวาดได้ไหม" · ช่องที่ดีไซน์พักไว้ (FOCUSED) ยังไม่ต้องใส่ · รูปที่ยังไม่มี = optional + placeholder
level string พิมพ์ผิดแล้วได้ป้ายว่างเงียบ ๆ · union "EASY" | "MEDIUM" | "HARD" ได้ TS2820 พร้อม Did you mean
4 สถานะ Default · Loading · Empty · Error ต้องมีครบ · เก็บเป็น discriminated union บน status ไม่ใช่ตัวแปร boolean แยก · Empty คือ success ที่ลิสต์ยาว 0 — tsc จับการลืมไม่ได้
onClick Server Component ส่งฟังก์ชันให้ Client Component ไม่ได้ · tsc และ build ผ่าน แต่หน้าได้ 500 · prop ชื่อมีขีด - TypeScript ไม่ตรวจ

✅ ถ้าตอบสี่ข้อนี้ได้ แปลว่าบทนี้ผ่านแล้ว
1. ทำไม imageUrl เป็น optional แต่ ratingCount เป็นช่องบังคับ?
2. ใส่ level: "EASYY" — ผลต่างกันยังไงระหว่าง type string กับ union?
3. ทำไมบทนี้ไม่มี status: "empty" แล้วหน้า Empty มาจากไหน?
4. หน้าที่ build ผ่านแต่เปิดแล้วได้ 500 เพราะส่ง onClick — สาเหตุคืออะไร?
ติดข้อไหน หรืออยากรู้ว่าต้องต่อ API จริงแล้ว Loading มาจากไหน — ถามกลับมาได้เลย

อ้างอิง