🧰 ของที่ใช้ตรวจในบทนี้
# 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):
- Recipe name / image / level
- Owner: owner image / owner name
- Rating: rating / rating count
- Is favorite? · onFavorite · onClick
แล้วสไลด์หน้า 46 ทำสิ่งที่สำคัญมาก — หัวข้อเปลี่ยนเป็น DESIGN PROPERTIES FOCUSED
และทำตัวอักษรให้จางลงสามรายการ: owner image · Is favorite? · onFavorite
แปลว่า "รอบนี้ยังไม่ทำ" · ดีไซน์วาดไว้ครบ แต่งานรอบแรกเลือกทำเฉพาะที่จำเป็นก่อน
ทีนี้แปลงรายการนั้นเป็นตาราง โดยถามทีละช่องว่า "ถ้าช่องนี้ไม่มี การ์ดยังวาดได้ไหม?"
| ในสไลด์ | ชื่อ field | ต้องมีไหม | เหตุผล |
|---|---|---|---|
| Recipe name | name: string | ✅ ต้องมี | การ์ดไม่มีชื่อ = ไม่รู้ว่าเป็นเมนูอะไร |
| image | imageUrl?: string | ⭕ ไม่มีก็ได้ | ในภาพดีไซน์ยังไม่มีรูปจริงสักใบ ทุกใบเป็นลายทางเขียนว่า dish photo — … → ต้องมีภาพสำรอง |
| level | level: RecipeLevel | ✅ ต้องมี | ป้าย Easy / Medium / Hard อยู่ทุกใบ · หัวข้อ 2 จะอธิบายว่าทำไมไม่ใช้ string |
| owner name | ownerName: string | ✅ ต้องมี | ทุกสูตรมีคนเขียน |
| owner image | — | ⏸ ยังไม่ทำ | สไลด์หน้า 46 ทำให้จาง (เก็บไว้เป็นภารกิจท้ายบท) |
| rating / rating count | rating: number · ratingCount: number | ✅ ต้องมี | การ์ดโชว์ ★ 4.9 (312) · สูตรใหม่ที่ยังไม่มีคนให้ดาวก็ยังมีค่า 0 ได้ |
| Is favorite? / onFavorite | — | ⏸ ยังไม่ทำ | สไลด์หน้า 46 ทำให้จาง · ต้องมีระบบ login ก่อนถึงจะรู้ว่า "ใคร" กดหัวใจ |
| onClick | onClick?: (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" | ✅ ไม่มีทางไปถึงหน้าเว็บ |
แบบสอบถามถามว่า "ระดับความเผ็ด" · ถ้าให้เขียนเอง จะมีคนเขียน "เผ็ดมก" "ผ็ดน้อย" "กลางๆ"
แล้วคนนับผลก็ไม่รู้จะนับเป็นช่องไหน — กระดาษรับทุกคำโดยไม่บ่น ·
ถ้าเปลี่ยนเป็นช่องติ๊ก ☐ น้อย ☐ กลาง ☐ มาก ก็ไม่มีทางกรอกคำแปลก ๆ ได้ตั้งแต่แรก
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 ตอบ · เน็ตช้า | หน้าขาว ๆ ไม่รู้ว่าเว็บค้างหรือกำลังโหลด |
| Empty | API ตอบสำเร็จ แต่ได้ลิสต์ว่าง · ค้นหาแล้วไม่เจอ | หน้าขาวเหมือนกัน — แยกไม่ออกจาก Loading |
| Error | API ล่ม · เน็ตหลุด | หน้าขาว หรือหน้าพังทั้งหน้า |
ท่าที่เขียนกันบ่อยคือเก็บสถานะเป็นตัวแปรแยกหลายตัว
(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)
- เพิ่ม field รูปคนเขียนลงใน
RecipeSummary— ตัดสินใจเองว่าต้องมีหรือไม่มีก็ได้ - ถ้ามีรูป ให้วาด
<img>วงกลม · ถ้าไม่มี ให้วาดวงกลมที่มีตัวย่อชื่อสองตัวอักษร (ตัวใหญ่) - ใส่รูปให้สูตรที่สามใน
mock.tsหนึ่งสูตร อีกสองสูตรไม่ต้องใส่ - พิสูจน์:
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'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 มาจากไหน — ถามกลับมาได้เลย
อ้างอิง
- TypeScript Handbook — Narrowing —
อ่านหัวข้อย่อย Discriminated unions ·
ตัวอย่างใน handbook ใช้รูปทรง (circle / square) แทนสถานะหน้า แต่หลักเดียวกับ
RecipeListStateเป๊ะ — ช่องร่วมที่เป็นคำตายตัว แล้วifตามช่องนั้น - TypeScript Handbook — Literal Types —
อ่านหัวข้อ Literal Types ถึงตัวอย่างที่ประกาศ
"left" | "right" | "center"· คือท่าเดียวกับRecipeLevelในหัวข้อ 2 - React — Conditional Rendering —
อ่านหัวข้อ Conditionally returning JSX ·
ท่า
if (…) return …หลายชั้นที่RecipeListใช้แยก 4 สถานะ - shadcn/ui — Skeleton —
ดูตัวอย่างโค้ดบนสุดของหน้า ว่าเขาต่อกล่อง
Skeletonหลายอันให้เป็นรูปการ์ดยังไง · แล้วเปิดcomponents/ui/skeleton.tsxในโปรเจกต์ของคุณเทียบ จะเห็นว่ามันเป็นแค่<div>หนึ่งอัน - Next.js — Server and Client Components — อ่านส่วนที่พูดเรื่องการส่งข้อมูลจาก Server Component ไป Client Component ที่บอกว่า props ต้อง serializable · คือเหตุผลของ error 500 ในหัวข้อ 4