🧰 ของที่ใช้ตรวจในบทนี้
Next.js v16.3.5 # npx next --version
react 19.2.8 # require('react/package.json').version
typescript 5.9.3
tailwindcss 4.3.3
@base-ui/react 1.8.0
eslint 9.39.5
node v24.14.0
โปรเจกต์ที่ใช้ตรวจสร้างด้วย create-next-app + shadcn init +
shadcn add button card badge skeleton · โค้ดทุกก้อนผ่าน
npx next build · npx tsc --noEmit · npx eslint ·
ข้อความ warning ได้จาก log ของ npx next dev ตอน curl หน้าเว็บ ·
ผลการกดปุ่มได้จากการเปิดหน้าใน browser แล้วกดจริง
ทุกข้อความ error · ตัวเลข · ผลลัพธ์ในบทนี้ก็อปมาจากหน้าจอจริง
ของที่สไลด์ไม่ได้ให้มา ผมจึงเขียนตัวแทนขึ้นเอง:
สไลด์หน้า 48–49 import ของสามอย่างที่ไม่มีโค้ดอยู่ในสไลด์ชุดนี้เลย —
Container จาก @/components/Bases ·
RecipeCard จาก @/components/RecipeCard ·
และไฟล์ ./recipes ที่เก็บ RECIPES_MOCK_DATA
ผมเขียนตัวแทนแบบเรียบที่สุดให้ build ได้ (RecipeCard รับ name · level ·
imageUrl · owner ตามที่หน้า 49 ส่งเข้าไป) ·
ข้อมูล mock ใช้ชื่อเมนู 8 ชื่อที่อ่านได้จากภาพสไลด์หน้า 50 และไม่ใส่รูป
เพราะไม่มีไฟล์รูปจริงให้ใช้
RecipeCard ใน บท 84 ออกแบบให้รับ recipe ก้อนเดียว
แต่สไลด์หน้า 49 ส่ง props ทีละช่อง — บทนี้ยึดตามสไลด์ · หลักการทุกข้อในบทนี้ใช้ได้กับทั้งสองแบบ
ส่วน class wongnok-text-h2 / wongnok-text-body มาจาก globals.css
ที่สไลด์หน้า 27 ให้ AI สร้าง (typography ที่ขึ้นต้นด้วย wongnok) —
ในโปรเจกต์ตรวจไม่มี class นี้ ซึ่งไม่ทำให้ build พัง แค่ตัวหนังสือไม่มีสไตล์
โจทย์จากสไลด์ — หน้าที่ต้องได้
สไลด์หน้า 47 โชว์หน้าเว็บที่ localhost:3000/recipes ใต้ Navbar จากบทก่อน ๆ
มีหัวข้อ All Recipes · บรรทัดรองสีเทา · แล้วเป็นตารางการ์ด 4 คอลัมน์
ทุกใบหน้าตาเหมือนกัน: รูปหมวกเชฟ · recipe 1, recipe 2, … ·
เจ้าของ JD john doe · ป้าย Easy
หน้า 50 คือผลสุดท้ายหลัง refactor: หน้าตาเดิมทุกอย่าง แต่การ์ดกลายเป็นเมนูจริง —
Classic Creamy Carbonara (Chef Marco · Medium) ·
Avocado Toast with Poached Egg (Sarah Jenkins · Easy) ·
Beef Wellington (Gordon Ramsey Style · Hard) ·
Spicy Thai Basil Chicken (Pad Krapow) (Noy Somchai · Easy) ·
French Macarons (Chloe Dubois · Hard) ·
Mediterranean Chickpea Salad (Elena Rostova · Easy) ·
Homemade Tonkotsu Ramen (Kenji Sato · Hard) ·
Mushroom Risotto (Chef Marco · Medium)
— และยังมีแถวที่ 3 โผล่ขอบล่าง แปลว่าข้อมูลจริงในสไลด์มีมากกว่า 8 ใบ
แต่อ่านชื่อได้แค่ 8 ใบ
จังหวะที่ 1 — วางการ์ดปลอม 12 ใบ (สไลด์หน้า 48)
งานแรกยังไม่ต้องมีข้อมูลจริง แค่อยากเห็นว่าตารางกับการ์ดวางตัวถูกไหม สไลด์ใช้ท่า “สร้าง array เปล่า 12 ช่อง แล้ว map ออกมาเป็นการ์ด”:
// app/recipes/page.tsx — ถอดจากสไลด์หน้า 48 ทุกตัวอักษร
import { Container } from "@/components/Bases";
import RecipeCard from "@/components/RecipeCard";
const RecipesPage = () => {
return (
<div className="pt-10 pb-8">
<Container>
<p className="wongnok-text-h2">All Recipes</p>
<p className="wongnok-text-body text-muted-foreground mt-2">
8 recipes from home cooks · page 1 of 7
</p>
<div className="grid grid-cols-4 gap-4 mt-7">
{Array.from({ length: 12 }).map((_, index) => (
<RecipeCard
key={`recipe-${index}`}
name={`recipe ${index + 1}`}
level="EASY"
owner={{ name: "john doe" }}
/>
))}
</div>
</Container>
</div>
);
};
export default RecipesPage;
build ผ่าน · หน้าเป็น ○ (Static) · curl หน้า /recipes-v1
(ชื่อ route ที่ผมใช้เก็บเวอร์ชันหน้า 48 ไว้เทียบ) แล้วนับได้การ์ด 12 ใบจริง:
$ curl -s http://localhost:385/recipes-v1 | grep -o 'recipe [0-9]*<' | wc -l
12
$ curl -s http://localhost:385/recipes-v1 | grep -o '8 recipes from home cooks[^<]*' | head -1
8 recipes from home cooks · page 1 of 7
// ทำไมสไลด์ต้องเขียน Array.from({ length: 12 })
// ไม่เขียน new Array(12) ซึ่งสั้นกว่า?
console.log(Array.from({ length: 3 }));
console.log(Array.from({ length: 3 }).map((_, index) => `recipe ${index + 1}`));
console.log(new Array(3).map((_, index) => `recipe ${index + 1}`));
สามบรรทัดนี้พิมพ์อะไรออกมา — บรรทัดที่ 2 กับ 3 ได้เหมือนกันไหม?
ผลรันจริงบน node v24.14.0:
[ undefined, undefined, undefined ]
[ 'recipe 1', 'recipe 2', 'recipe 3' ]
[ <3 empty items> ]
ไม่เหมือนกัน · new Array(3) ได้ array ที่ยาว 3 แต่
ทุกช่องเป็น “ช่องโหว่” (empty item) ไม่ใช่ undefined —
และ .map() ข้ามช่องโหว่ทิ้งหมด ฟังก์ชันข้างในไม่ถูกเรียกเลยสักครั้ง
ถ้าเขียนแบบนั้นใน JSX หน้าเว็บจะว่างเปล่าเงียบ ๆ ไม่มี error
Array.from({ length: 3 }) สร้างช่องที่มีค่า undefined จริง ๆ
map จึงวิ่งครบ · _ ในสไลด์คือชื่อที่คนนิยมตั้งให้พารามิเตอร์ที่ตั้งใจไม่ใช้
(เราต้องการแค่ index)
การ์ด 12 ใบที่ชื่อ recipe 1 … recipe 12 คืออาหารพลาสติกในตู้โชว์หน้าร้านญี่ปุ่น
ร้านยังไม่ต้องทำซูชิจริงสักคำ ก็จัดตู้ได้แล้วว่าจานวางตรงไหน ตู้กี่ชั้น ป้ายราคาอ่านชัดไหม ·
พอเมนูจริงพร้อม ค่อยสลับของจริงเข้าไปในตำแหน่งเดิม — นี่คือหน้า 48 → หน้า 50 พอดี
ที่ไม่เหมือนกัน: อาหารพลาสติกไม่มีใครเผลอกิน
แต่ข้อมูลปลอมในโค้ดหลุดขึ้น production ได้จริงและไม่มีอะไรฟ้อง —
ข้อความ 8 recipes · page 1 of 7 ก็คือของปลอมชิ้นหนึ่งที่ติดมาถึงหน้า 50 แล้ว (ดูหัวข้อถัดไป)
และอาหารพลาสติกหน้าตาเหมือนของจริง แต่ mock data มัก “สวยเกินจริง” —
ชื่อสั้นทุกใบ มีรูปทุกใบ · ของจริงจะมีชื่อยาวสามบรรทัดกับเมนูที่ไม่มีรูป ซึ่ง mock ไม่เคยทดสอบให้
สามอย่างในหน้า 48 ที่ติดมาถึงหน้า 50
หน้า 48 วาดการ์ด 12 ใบ แต่ข้อความบนหัวบอก 8 recipes ·
และทั้งโปรเจกต์ยังไม่มีระบบแบ่งหน้าเลย แต่บอกว่า page 1 of 7
ที่มาของมันเห็นได้ชัดในสไลด์หน้า 42 — นี่คือข้อความจากภาพออกแบบที่ Claude Design สร้างให้
ซึ่งถูกพิมพ์ตามลงโค้ดตรง ๆ · หน้า 49 ที่ refactor แล้วก็ยังพิมพ์ข้อความนี้ไว้เหมือนเดิม
และหน้า 50 ก็ยังโชว์ข้อความนี้อยู่
ไม่ผิดสำหรับขั้น mock · แต่ต้องรู้ว่ามันคือของปลอม ·
ตัวเลขนี้ควรคำนวณจากข้อมูล เช่น `${state.recipes.length} recipes found`
(เฉลยภารกิจท้ายบททำให้ดู) ส่วน “page 1 of 7” ต้องรอจนมีการแบ่งหน้าจริงจาก API
grid-cols-4 คือ 4 คอลัมน์ทุกขนาดจอ
ไม่มี prefix อะไรนำหน้า = ใช้กับทุกความกว้าง · คิดเลขง่าย ๆ บนมือถือกว้าง 375px:
หักขอบซ้ายขวาของ Container ตัวแทนที่ผมเขียน (px-4 = 16px × 2) กับช่องไฟ gap-4 สามช่อง (16px × 3)
เหลือ 295px แบ่ง 4 ใบ = ใบละไม่ถึง 74px (ความกว้างราวนิ้วโป้งหนึ่งนิ้ว)
ทั้งที่ prompt ออกแบบในสไลด์หน้า 23 ขอไว้เองว่า
“Grid of RecipeCard (4 columns desktop, 2 columns tablet, 1 column mobile)”
· แก้เป็น grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 — รายละเอียดอยู่หัวข้อ responsive ข้างล่าง
key ทำจาก index
key={`recipe-${index}`} ในหน้า 48 ไม่ทำให้เกิด warning
เพราะ key ไม่ซ้ำกันจริง · และในหน้านี้ยังไม่เป็นบั๊กเพราะรายการไม่เคยเปลี่ยนลำดับ
หน้า 49 แก้เป็น key={recipe.id} ถูกต้องแล้ว — หัวข้อ key ข้างล่างจะพิสูจน์ว่าทำไมต้องเปลี่ยน
จังหวะที่ 2 — แยกรายการออกเป็น container (สไลด์หน้า 49)
สไลด์หน้า 49 เขียนเหตุผลไว้หนึ่งบรรทัด: “Split recipe list into container to handle business of recipe list”
คำว่า container ย้อนกลับไปที่สไลด์หน้า 8 ซึ่งแบ่งโฟลเดอร์ไว้สองแบบ:
| โฟลเดอร์ | สไลด์หน้า 8 นิยามว่า | ในบทนี้คือ |
|---|---|---|
components/ |
pure and presentational — รับค่ามาแล้ววาดอย่างเดียว | RecipeCard — ไม่รู้ว่าข้อมูลมาจากไหน รู้แค่ว่าได้ name มาก็วาด name |
containers/ |
stateful composition of components by business requirement | RecipeList — รู้ว่า “รายการสูตรอาหาร” มาจากไหน แล้วเอา RecipeCard มาเรียงให้ |
โค้ดหน้า 49 มีสองก้อน · ผมวางไฟล์ตามโครงหน้า 8
(containers/RecipeList/RecipeList.tsx + index.ts) —
สไลด์ไม่ได้บอกชื่อไฟล์ของ RecipeList ไว้ บอกแค่ว่ามันอยู่โฟลเดอร์เดียวกับ ./recipes
// containers/RecipeList/RecipeList.tsx — ถอดจากสไลด์หน้า 49 (ก้อนขวา) ทุกตัวอักษร
"use client";
import RecipeCard from "@/components/RecipeCard";
import { RECIPES_MOCK_DATA } from "./recipes";
const RecipeList = () => {
return (
<>
{RECIPES_MOCK_DATA.map((recipe) => (
<RecipeCard
key={recipe.id}
name={recipe.name}
level={recipe.level}
imageUrl={recipe.imageUrl}
owner={recipe.owner}
/>
))}
</>
);
};
export default RecipeList;
// app/recipes/page.tsx — ส่วน JSX ตามสไลด์หน้า 49 (ก้อนซ้าย) · บรรทัด import ผมเติมเอง
import { Container } from "@/components/Bases";
import RecipeList from "@/containers/RecipeList";
const RecipesPage = () => {
return (
<div className="pt-10 pb-8">
<Container>
<p className="wongnok-text-h2">All Recipes</p>
<p className="wongnok-text-body text-muted-foreground mt-2">
8 recipes from home cooks · page 1 of 7
</p>
<div className="grid grid-cols-4 gap-4 mt-7">
<RecipeList />
</div>
</Container>
</div>
);
};
export default RecipesPage;
// containers/RecipeList/recipes.ts — สไลด์ไม่ได้โชว์ไฟล์นี้ ผมเขียนจากภาพหน้า 50
import type { RecipeLevel, RecipeOwner } from "@/components/RecipeCard";
export type Recipe = {
id: string;
name: string;
level: RecipeLevel; // "EASY" | "MEDIUM" | "HARD"
imageUrl?: string;
owner: RecipeOwner; // { name: string; imageUrl?: string }
};
export const RECIPES_MOCK_DATA: Recipe[] = [
{ id: "r001", name: "Classic Creamy Carbonara", level: "MEDIUM", owner: { name: "Chef Marco" } },
{ id: "r002", name: "Avocado Toast with Poached Egg", level: "EASY", owner: { name: "Sarah Jenkins" } },
{ id: "r003", name: "Beef Wellington", level: "HARD", owner: { name: "Gordon Ramsey Style" } },
// … r004 Pad Krapow · r005 French Macarons · r006 Chickpea Salad
// … r007 Tonkotsu Ramen · r008 Mushroom Risotto
];
ตัวแทน Container กับ RecipeCard ที่ผมใช้ตรวจ (ไม่ใช่โค้ดจากสไลด์ ·
ตัวเลขขนาดไฟล์ในหัวข้อ "use client" ข้างล่างวัดจากตัวแทนชุดนี้ ถ้า RecipeCard ของคุณต่างไป ตัวเลขจะต่างตาม):
// components/Bases/index.tsx — ตัวแทน
export const Container = ({ children }: { children: React.ReactNode }) => (
<div className="mx-auto max-w-7xl px-4">{children}</div>
);
// components/RecipeCard/RecipeCard.tsx — ตัวแทน (index.ts export default + type ต่อ)
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
export type RecipeLevel = "EASY" | "MEDIUM" | "HARD";
export type RecipeOwner = { name: string; imageUrl?: string };
type RecipeCardProps = { name: string; level: RecipeLevel; imageUrl?: string; owner: RecipeOwner };
const RecipeCard = ({ name, level, imageUrl, owner }: RecipeCardProps) => {
return (
<Card className="pt-0">
<div className="aspect-[4/3] bg-muted">{imageUrl ? null : "no photo"}</div>
<CardHeader>
<CardTitle>{name}</CardTitle>
</CardHeader>
<CardContent>
<p>{owner.name}</p>
<Badge variant="secondary">{level}</Badge>
</CardContent>
</Card>
);
};
export default RecipeCard;
ผลรัน: build ผ่าน · tsc และ eslint exit code 0 ·
curl หน้า /recipes เจอการ์ด 8 ใบ ชื่อเมนูตรงกับหน้า 50
$ npx next build
Route (app)
┌ ○ /
├ ○ /_not-found
├ ○ /recipes
└ ○ /recipes-v1
○ (Static) prerendered as static content
RecipeCard คือจานเปล่าหนึ่งใบ — วางอะไรลงไปก็เสิร์ฟหน้าตานั้น
จานไม่รู้และไม่ต้องรู้ว่าวันนี้ครัวทำเมนูอะไร
RecipeList คือหัวหน้าครัว — เป็นคนเดียวที่รู้ว่า “เมนูวันนี้มีอะไรบ้าง”
แล้วหยิบจานมาจัดทีละใบ · หน้า page.tsx คือโต๊ะในร้าน ที่แค่บอกว่า “ตรงนี้วางอาหาร”
วันที่ย้ายจาก mock ไปเรียก API จริง คนที่ต้องเปลี่ยนมีแค่หัวหน้าครัว — จานกับโต๊ะไม่ต้องแตะ
ที่ไม่เหมือนกัน: หัวหน้าครัวจริงเป็นคนละคนกับจานชัดเจน
แต่ใน React ทั้งสองอย่างเป็นฟังก์ชันหน้าตาเหมือนกันทุกประการ ·
ไม่มี keyword อะไรบอกว่าไฟล์ไหนเป็น component ไฟล์ไหนเป็น container —
มีแค่ชื่อโฟลเดอร์ที่ทีมตกลงกันเอง ใครจะแอบ import ข้อมูลเข้าไปใน RecipeCard ก็ build ผ่าน
และสไลด์หน้า 8 นิยามว่า container เป็น stateful — แต่ RecipeList ในหน้า 49
ไม่มี state เลยสักตัว ซึ่งพาไปสู่คำถามเรื่อง "use client" ข้างล่าง
- mock data
- ข้อมูลปลอมที่เขียนไว้ในโค้ดตรง ๆ เพื่อทำหน้าจอให้เสร็จก่อนที่ API จะพร้อม ·
ตั้งชื่อให้ตะโกนว่าเป็นของปลอม (
RECIPES_MOCK_DATA) เพื่อให้ค้นเจอง่ายตอนจะลบ · บอก AI ได้ว่า “generate 12 mock recipes matching theRecipetype, include one with a very long name and one without an image” — ขอเคสขอบไว้ด้วย ไม่ใช่ขอแค่ข้อมูลสวย ๆ - presentational component / container component
- แบ่งตามหน้าที่: ตัวแรก “รับ props แล้ววาด” · ตัวหลัง “รู้ว่าข้อมูลมาจากไหน แล้วส่งต่อให้ตัวแรก” · ในบางทีมเรียกว่า dumb / smart component · ถาม senior ได้ว่า “ทีมเราแยก container ด้วยโฟลเดอร์ หรือด้วยการตั้งชื่อ”
- hardcode
- ค่าที่พิมพ์ตายตัวในโค้ดแทนที่จะคำนวณหรือดึงมา เช่น
8 recipes · page 1 of 7· ไม่ผิดตอนทำ mock แต่ต้องมีรายการว่ามีกี่จุด · ประโยคที่ใช้ได้ใน code review: “ตัวเลขนี้ hardcode อยู่ ควร derive จากrecipes.lengthไหม”
key — ทำไมหน้า 49 เปลี่ยนจาก index เป็น recipe.id
key คือป้ายชื่อที่ React ใช้จำว่าการ์ดใบไหนคือใบเดิมจากรอบก่อน
เริ่มจากกรณีที่ลืมใส่เลย · ผมลบบรรทัด key ออกจากทั้งสองเวอร์ชัน
(หน้า 48 ที่เป็น server component → route /nokey ·
หน้า 49 ที่ติด "use client" → route /nokey-client
ซึ่งตอนนั้นผมวางไฟล์ไว้ที่ components/RecipeListNoKey/)
แล้ว curl ทีละหน้า · build ผ่านทั้งคู่ แต่ log ของ next dev ออกมาแบบนี้:
GET /recipes 200 in 170ms (next.js: 145ms, application-code: 25ms)
Each child in a list should have a unique "key" prop. See https://react.dev/link/warning-keys for more information.
GET /nokey 200 in 125ms (next.js: 98ms, application-code: 27ms)
Each child in a list should have a unique "key" prop.
Check the render method of `RecipeList`. See https://react.dev/link/warning-keys for more information.
GET /nokey-client 200 in 85ms (next.js: 71ms, application-code: 14ms)
อ่าน log นี้ให้เป็น: ข้อความ warning ขึ้นก่อนบรรทัด GET ของหน้าที่ทำให้เกิด
(หน้า /recipes ที่มี key ครบไม่มี warning) ·
เวอร์ชัน client บอกชื่อ component ให้ด้วย (Check the render method of `RecipeList`)
ส่วนเวอร์ชัน server ไม่บอก · และ eslint จับได้ทั้งคู่เป็น error
(ผมตัด path เต็มของเครื่องที่ใช้ตรวจออกเป็น ...):
$ npx eslint app/nokey components/RecipeListNoKey
.../app/nokey/page.tsx
14:13 error Missing "key" prop for element in iterator react/jsx-key
.../components/RecipeListNoKey/index.tsx
10:9 error Missing "key" prop for element in iterator react/jsx-key
✖ 2 problems (2 errors, 0 warnings)
ลืมใส่มีคนฟ้อง · ใส่เป็น index ไม่มีใครฟ้องเลย — นั่นคือเหตุผลที่ข้อถัดไปอันตรายกว่า
ผมทำหน้าทดลองหนึ่งหน้า: แต่ละแถวมีปุ่มหัวใจที่เก็บ useState ของตัวเอง
(ตรงกับ “Is favorite?” ในสไลด์หน้า 45) · วางรายการเดียวกันสองชุด ชุดบนใช้ key จาก index ชุดล่างใช้ key จาก id
· มีปุ่ม remove first ลบเมนูแรกออกจากทั้งสองชุดพร้อมกัน
// ส่วนสำคัญของหน้าทดลอง (ไฟล์เต็มมี "use client" + import useState)
const FavoriteRow = ({ name }: { name: string }) => {
const [isFavorite, setIsFavorite] = useState(false);
return (
<li>
<button onClick={() => setIsFavorite(!isFavorite)}>
{isFavorite ? "♥" : "♡"} {name}
</button>
</li>
);
};
// ชุดบน
{recipes.map((recipe, index) => <FavoriteRow key={`recipe-${index}`} name={recipe.name} />)}
// ชุดล่าง
{recipes.map((recipe) => <FavoriteRow key={recipe.id} name={recipe.name} />)}
// ปุ่ม remove first
<button onClick={() => setRecipes(recipes.slice(1))}>remove first</button>
// ขั้นที่ 1: กดหัวใจ Carbonara ทั้งสองชุด
key=index key=id
♥ Classic Creamy Carbonara ♥ Classic Creamy Carbonara
♡ Avocado Toast with Poached Egg ♡ Avocado Toast with Poached Egg
♡ Beef Wellington ♡ Beef Wellington
// ขั้นที่ 2: กด remove first → Carbonara หายไปจากทั้งสองชุด
หลังกด remove first แต่ละชุดจะเหลือหัวใจทึบอยู่ที่ไหน?
ผลจริงจากการกดใน browser (อ่านข้อความบนหน้าหลังกด):
remove first
key=index
♥ Avocado Toast with Poached Egg
♡ Beef Wellington
key=id
♡ Avocado Toast with Poached Egg
♡ Beef Wellington
ชุด key=index: หัวใจย้ายไปอยู่ที่ Avocado Toast ทั้งที่ไม่มีใครกด
เพราะ React เห็นว่ารอบก่อนมี key recipe-0, recipe-1, recipe-2
รอบนี้มี recipe-0, recipe-1 → มันสรุปว่า
“ใบสุดท้ายหายไป สองใบแรกยังอยู่ที่เดิม แค่ชื่อเปลี่ยน” ·
state หัวใจของ recipe-0 จึงอยู่ต่อ แล้วไปติดกับชื่อใหม่ที่ขยับขึ้นมาแทน
ชุด key=id ถูกต้อง — r001 หายไปพร้อม state ของมัน
r002 ยังเป็นใบเดิมที่ไม่เคยถูกกด
ในแอปจริง อาการนี้คือ “ผู้ใช้กด favorite เมนูหนึ่ง แล้วหลังกรอง / เรียง / ลบ หัวใจไปโผล่อีกเมนู”
— และไม่มี warning ที่ไหนเลย: ผมกดซ้ำอีกรอบแล้วอ่าน console ของ browser
เจอแค่ข้อความชวนติดตั้ง React DevTools กับ [HMR] connected
key จาก index คือร้านกาแฟที่จำลูกค้าตามลำดับคิว — “คิวที่ 1 สั่งลาเต้หวานน้อย”
คนแรกเปลี่ยนใจเดินออกจากคิวไป คนที่สองขยับขึ้นมาเป็นคิวที่ 1 ·
บาริสต้ายื่นลาเต้หวานน้อยให้คนที่สองทันที ทั้งที่เขาสั่งอเมริกาโน่
key จาก id คือเขียนชื่อลูกค้าบนแก้ว — ใครออกจากคิวไป แก้วของคนนั้นก็หายไปด้วย คนอื่นไม่โดนสลับ
ที่ไม่เหมือนกัน: บาริสต้าจริงเห็นหน้าลูกค้า รู้สึกได้ว่าผิดคน
แต่ React ไม่มีทางรู้เลยว่าเมนูที่ขยับขึ้นมาเป็นคนละเมนู — มันเชื่อ key อย่างเดียว
และถ้าคิวไม่เคยขยับ (รายการคงที่ ไม่มี state ข้างใน เหมือนการ์ดปลอม 12 ใบในหน้า 48)
การจำตามลำดับก็ไม่ผิดอะไรเลย · ปัญหาเกิดตอนรายการเปลี่ยนเท่านั้น
- stable key / stable identity
- key ที่ติดกับ “ตัวข้อมูล” ไม่ใช่ “ตำแหน่ง” — ย้ายที่ กรอง เรียงใหม่ ก็ยังเป็นค่าเดิม ·
ปกติคือ
idจากฐานข้อมูล · ถาม senior ได้ว่า “API ของ recipe มี id ที่ unique และไม่เปลี่ยนไหม จะใช้เป็น key” - reconciliation
- ขั้นที่ React เทียบ UI รอบก่อนกับรอบนี้ เพื่อตัดสินว่าอะไรคงไว้ อะไรสร้างใหม่ อะไรทิ้ง · key คือสิ่งที่มันใช้จับคู่ลูกในรายการ · พิมพ์บอก AI ได้ว่า “state jumps to another row after I remove an item — check my list keys”
react/jsx-key- ชื่อกฎ eslint ที่จับ “ลืมใส่ key” · จับไม่ได้ว่าใช้ index · เวลาเห็นชื่อกฎนี้ใน CI ให้รู้ว่าเป็นเรื่อง key ทันที
"use client" ใน RecipeList — จำเป็นไหม
RecipeList หน้า 49 ไม่มี useState · ไม่มี useEffect ·
ไม่มี onClick · มีแค่ .map() ข้อมูลที่ import มา
ใน บท 78 เราเห็นแล้วว่า
ลืม "use client" ในไฟล์ที่มี hook → build พัง ·
คำถามกลับด้าน: ใส่ในไฟล์ที่ไม่มี hook เลย จะเกิดอะไร
อย่าเดา · ผม build ทั้งสองแบบ (ลบ .next ทิ้งก่อนทุกครั้ง) แล้ววัดของที่ต่างกันจริง
// แบบ A — ตามสไลด์หน้า 49
"use client";
import RecipeCard from "@/components/RecipeCard";
import { RECIPES_MOCK_DATA } from "./recipes";
// แบบ B — ลบสองบรรทัดแรกทิ้ง ที่เหลือเหมือนเดิมทุกตัวอักษร
import RecipeCard from "@/components/RecipeCard";
import { RECIPES_MOCK_DATA } from "./recipes";
แบบ B build ผ่านไหม? ถ้าผ่าน หน้า /recipes ต่างจากแบบ A ตรงไหน?
ผ่านทั้งคู่ · ตาราง route เหมือนกันทุกบรรทัด · tsc และ eslint exit code 0 ทั้งคู่
· ชื่อเมนูอยู่ใน HTML ที่ prerender ไว้ทั้งคู่ (grep -o 'Mushroom Risotto' เจอทั้งสองแบบ) ·
หน้าตาบนจอเหมือนกัน
ความต่างอยู่ที่ JavaScript ที่ส่งไปให้ browser — ผมเขียนสคริปต์อ่านแท็ก
<script src> ใน .next/server/app/recipes.html แล้วรวมขนาดไฟล์ JS ที่หน้านี้โหลด
พร้อมค้นว่าไฟล์ไหนมีคำว่า Mushroom Risotto
(ป้าย A / B หน้าบรรทัดคือชื่อรอบที่ผมส่งให้สคริปต์):
A html=21153 html_gz=3222 js_files=7 js=599772 js_gz=186078 total_gz=189300 ['static/chunks/1ltawcz6gcos9.js'] html_hits 1
B html=37091 html_gz=3775 js_files=7 js=565979 js_gz=172654 total_gz=176429 [] html_hits 2
ไฟล์ที่มีข้อมูล mock ของแบบ A:
$ f=$(grep -rl "Mushroom Risotto" .next/static/chunks)
$ ls -l $f | awk '{print $5}' # ขนาดไฟล์เป็นไบต์
34057
$ gzip -c $f | wc -c
13671
$ grep -o 'Classic Creamy Carbonara\|Gordon Ramsey Style\|base-ui\|no photo' $f | sort | uniq -c
1 Classic Creamy Carbonara
1 Gordon Ramsey Style
1 base-ui
1 no photo
แปลว่า: แบบ A ส่งไฟล์ JS ขนาด 34,057 ไบต์ (13,671 ไบต์หลัง gzip) ที่แบบ B ไม่มี
ข้างในมีข้อมูล mock ทั้งก้อน · โค้ด RecipeCard · และ Badge จาก @base-ui
ที่ RecipeCard ลากมาด้วย · (จำนวนไฟล์ที่หน้าโหลดยังเป็น 7 เท่ากัน แต่รวม JS ของแบบ B เล็กกว่า 33,793 ไบต์)
แต่ยังไม่จบ — ผลข้างบนคือกรณีที่ RecipeCard ไม่มี "use client"
ส่วน RecipeCard ใน บท 84 มี
(เพราะมี onClick) · ผมจึงวัดครบทั้ง 4 แบบ ·
ตัวเลขคือขนาดไฟล์ HTML ของหน้า + ไฟล์ JS ทุกไฟล์ที่หน้านี้โหลด บีบด้วย gzip ระดับ 9 (ไบต์ ·
ป้าย A–D หน้าบรรทัดผมพิมพ์เอง):
# A = card server · list client (ตามสไลด์) B = card server · list server
# C = card client · list server D = card client · list client
A html=21153 html_gz=3223 js_files=7 js=599772 js_gz=186078 total_gz=189301 ['static/chunks/1ltawcz6gcos9.js'] html_hits 1
B html=37091 html_gz=3775 js_files=7 js=565979 js_gz=172654 total_gz=176429 [] html_hits 2
C html=22333 html_gz=3380 js_files=7 js=598834 js_gz=185664 total_gz=189044 [] html_hits 2
D html=21153 html_gz=3221 js_files=7 js=599738 js_gz=186066 total_gz=189287 ['static/chunks/3t8oefhj0iumv.js'] html_hits 1
(html_hits = จำนวนครั้งที่เจอ Mushroom Risotto ใน HTML · ตัวเลขขยับได้ 1–2 ไบต์ระหว่างการ build แต่ละรอบ)
| RecipeCard | ลบ "use client" ออกจาก RecipeList แล้ว | ผลรวมหลัง gzip |
|---|---|---|
ไม่มี "use client" (A → B) |
ไฟล์ JS ที่มีข้อมูล mock หายไป · แต่ HTML โตขึ้น 21,153 → 37,091 ไบต์ | 189,301 → 176,429 · เล็กลง 12,872 ไบต์ |
มี "use client" แบบบท 84 (D → C) |
ข้อมูล mock ย้ายจากไฟล์ JS ไปอยู่ใน HTML | 189,287 → 189,044 · เล็กลงแค่ 243 ไบต์ |
HTML โตขึ้นเพราะอะไร: หน้าใน App Router ส่ง HTML ที่วาดแล้วพร้อมกับข้อมูลโครงหน้า
(RSC payload) ฝังท้ายไฟล์ · พอการ์ดวาดบน server ทั้งหมด ข้อมูลโครงหน้าก็ต้องบรรยายการ์ดทุกใบพร้อม class ยาว ๆ ของ shadcn
· กรณี C ข้อมูล mock ก็ยังต้องเดินทางไป browser อยู่ดี เพราะการ์ดที่เป็น client ต้องได้ props ไปวาดใหม่
(grep -o 'Mushroom Risotto' ใน HTML ของกรณี C เจอ 2 ครั้ง · ไม่เจอในไฟล์ JS)
ข้อสรุปตามผลจริง: "use client" ในหน้า 49 ไม่จำเป็น — build ผ่านทั้ง 4 แบบ · route เป็น ○ Static ทุกแบบ ·
ส่วน “เอาออกแล้วได้อะไร” ขึ้นกับลูกของมัน: ถ้าลูกเป็น client อยู่แล้ว ต่างกันแค่หลักร้อยไบต์ ·
ถ้าลูกเป็น server ได้หลักหมื่นไบต์ · ใส่ไว้ไม่ผิด แต่อย่าใส่เพราะคิดว่า container ต้องเป็น client
"use client" จริง
ตอนที่มันเริ่มเป็น stateful ตามนิยามสไลด์หน้า 8 จริง ๆ —
เช่นเก็บคำค้นด้วย useState (ช่อง “Search recipes by name” ในสไลด์หน้า 42) ·
หรือส่งฟังก์ชัน onClick ลงไปให้การ์ด (บท 84 หัวข้อ 4 รันให้ดูแล้วว่าส่งฟังก์ชันจาก server ไป client ไม่ได้ ได้ 500)
ก่อนถึงตอนนั้นปล่อยเป็น Server Component ไว้ ·
และถ้าจะวัดว่าคุ้มไหม ให้วัดทั้ง JS และ HTML อย่างที่ทำข้างบน ไม่ใช่ดูแค่ JS
- client boundary
- ไฟล์ที่ติด
"use client"คือเส้นแบ่ง — ไฟล์นั้นและทุกอย่างที่มัน import ถูกส่งเป็น JS ไปให้ browser ด้วย (RecipeCard กับข้อมูล mock ในกรณี A ข้างบน) · ถาม senior ได้ว่า “boundary ตรงนี้ดันลงไปที่การ์ดใบเดียวได้ไหม” - client bundle
- ไฟล์ JavaScript ที่ browser ต้องดาวน์โหลด · ตรวจเองได้ด้วยการดูไฟล์ใน
.next/static/chunksหลัง build อย่างที่ทำข้างบน · อย่าลืมว่า HTML ก็มีน้ำหนัก (RSC payload) — ย้ายของออกจาก bundle บางทีแค่ย้ายไปอยู่ใน HTML · พิมพ์บอก AI ได้ว่า “does this component need to be a client component? it has no hooks or event handlers”
ทำให้ตาราง responsive
Tailwind เขียนแบบ mobile-first: class ที่ไม่มี prefix ใช้กับทุกจอ ·
sm: ใช้ตั้งแต่จอกว้างขึ้นไป · lg: ใช้ตั้งแต่จอที่กว้างกว่านั้น
(ทบทวนได้ใน บท 72)
สไลด์หน้า 48–49
<div className="grid grid-cols-4 gap-4 mt-7">
4 คอลัมน์ทุกจอ · มือถือ 375px ได้การ์ดใบละไม่ถึง 74px
ที่เสนอ
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mt-7">
มือถือ 1 · แท็บเล็ต 2 · จอใหญ่ 4 ตรงกับ prompt หน้า 23
ขอบของ sm กับ lg อยู่ที่เท่าไหร่ — ไม่ต้องจำ ดูจาก CSS ที่ build ออกมาจริง:
$ grep -o '@media[^{]*{[^{]*sm\\:grid-cols-2' .next/static/chunks/*.css
@media (min-width:40rem){.sm\:grid-cols-2
$ grep -o '@media[^{]*{[^{]*lg\\:grid-cols-4' .next/static/chunks/*.css
@media (min-width:64rem){.lg\:grid-cols-4
40rem = 640px · 64rem = 1024px (1rem = 16px ตามค่าเริ่มต้นของ browser) ·
จอกว้าง 375px ได้ 1 คอลัมน์ · 700px ได้ 2 · 1280px ได้ 4
ภารกิจ — ต่อสถานะ Loading / Empty เข้ากับหน้านี้
สไลด์หน้า 42–43 ออกแบบหน้า All Recipes ไว้ 4 สถานะ (Default · Empty · Loading · Error)
และ บท 84 ทำ RecipeListState
ไว้เป็น union ที่มีป้าย status แล้ว (ไม่ใช้ตัวแปรแยก isLoading / error
เพราะมันเปิดช่องให้เกิดสถานะที่เป็นไปไม่ได้) ·
แต่ RecipeList ในหน้า 49 ทำได้แค่ Default เพราะมันดึงข้อมูลเองจากไฟล์ mock ตรง ๆ
ไม่มีทางบอกมันว่า “กำลังโหลด” หรือ “ค้นไม่เจอ”
🎯 ภารกิจ
- เปลี่ยน
RecipeListของหน้า 49 ให้รับstate: RecipeListStateทาง props แทนการ import mock เอง · ใช้ union สามแบบเหมือนบท 84 (loading·error·success) แต่ให้successถือRecipe[]ของบทนี้ loading→ วาดSkeletonของ shadcn 8 ช่องsuccessแต่ลิสต์ว่าง → ข้อความNo recipes match your searchกับลิงก์Clear filtersตามสไลด์หน้า 43 โดยให้กล่องนี้กินเต็มความกว้างของ grid ไม่ใช่อัดอยู่ช่องแรกsuccessที่มีข้อมูล → วาดการ์ดด้วยkey={recipe.id}เหมือนเดิม- ที่
page.tsx: ใช้ grid แบบ responsive · และเปลี่ยนข้อความหัว8 recipes · page 1 of 7ให้คำนวณจาก state — ตอนโหลดต้องไม่ขึ้นตัวเลข - ไม่ใส่
"use client"ใน RecipeList — แล้วพิสูจน์ว่า build ·tsc·eslintผ่านทั้งสามตัว และcurlนับได้ว่าแต่ละสถานะวาดของออกมาถูกจำนวน
// containers/RecipeList/RecipeList.tsx
import Link from "next/link";
import RecipeCard from "@/components/RecipeCard";
import { Skeleton } from "@/components/ui/skeleton";
import type { Recipe } from "./recipes";
export type RecipeListState =
| { status: "loading" }
| { status: "error"; message: string }
| { status: "success"; recipes: Recipe[] };
const RecipeList = ({ state }: { state: RecipeListState }) => {
if (state.status === "loading") {
return (
<>
{Array.from({ length: 8 }).map((_, index) => (
<Skeleton key={`skeleton-${index}`} className="aspect-[4/3] w-full" />
))}
</>
);
}
if (state.status === "error") {
return (
<div role="alert" className="col-span-full py-16 text-center">
<p className="font-medium">Something went wrong</p>
<p className="text-muted-foreground">{state.message}</p>
</div>
);
}
if (state.recipes.length === 0) {
return (
<div className="col-span-full py-16 text-center">
<p className="font-medium">No recipes match your search</p>
<Link href="/recipes" className="text-primary underline">
Clear filters
</Link>
</div>
);
}
return (
<>
{state.recipes.map((recipe) => (
<RecipeCard
key={recipe.id}
name={recipe.name}
level={recipe.level}
imageUrl={recipe.imageUrl}
owner={recipe.owner}
/>
))}
</>
);
};
export default RecipeList;
// containers/RecipeList/index.ts
export { default } from "./RecipeList";
export type { RecipeListState } from "./RecipeList";
// app/recipes/page.tsx
import { Container } from "@/components/Bases";
import RecipeList, { type RecipeListState } from "@/containers/RecipeList";
import { RECIPES_MOCK_DATA } from "@/containers/RecipeList/recipes";
const subtitle = (state: RecipeListState) => {
if (state.status === "loading") return "Loading recipes…";
if (state.status === "error") return "Could not load recipes";
return `${state.recipes.length} recipes found`;
};
const RecipesPage = () => {
const state: RecipeListState = { status: "success", recipes: RECIPES_MOCK_DATA };
return (
<div className="pt-10 pb-8">
<Container>
<p className="wongnok-text-h2">All Recipes</p>
<p className="wongnok-text-body text-muted-foreground mt-2">{subtitle(state)}</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mt-7">
<RecipeList state={state} />
</div>
</Container>
</div>
);
};
export default RecipesPage;
เพื่อตรวจสองสถานะที่เหลือ ผมทำหน้าก็อปอีกสองหน้า ต่างจากข้างบนแค่บรรทัด state
(และลบ import ที่ไม่ได้ใช้ ไม่งั้น eslint เตือน no-unused-vars):
$ diff app/recipes/page.tsx app/recipes-empty/page.tsx
3d2
< import { RECIPES_MOCK_DATA } from "@/containers/RecipeList/recipes";
12c11
< const state: RecipeListState = { status: "success", recipes: RECIPES_MOCK_DATA };
---
> const state: RecipeListState = { status: "success", recipes: [] };
$ diff app/recipes/page.tsx app/recipes-loading/page.tsx
3d2
< import { RECIPES_MOCK_DATA } from "@/containers/RecipeList/recipes";
12c11
< const state: RecipeListState = { status: "success", recipes: RECIPES_MOCK_DATA };
---
> const state: RecipeListState = { status: "loading" };
ผลตรวจจริง (RecipeCard ในโปรเจกต์ตรวจตอนนี้ติด "use client" แบบบท 84):
$ npx next build
Route (app)
┌ ○ /
├ ○ /_not-found
├ ○ /recipes
├ ○ /recipes-empty
├ ○ /recipes-loading
└ ○ /recipes-v1
○ (Static) prerendered as static content
$ npx tsc --noEmit; echo "tsc rc=$?"
tsc rc=0
$ npx eslint components containers app; echo "eslint rc=$?"
eslint rc=0
แล้ว npx next start -p 385 · curl ทั้งสามหน้า
นับ data-slot="card" (การ์ดของ shadcn) กับ data-slot="skeleton"
ในส่วน <body> ดึงข้อความบรรทัดหัว และค้นข้อความของ empty state:
--- /recipes
card 8 skeleton 0
['8 recipes found']
[]
--- /recipes-empty
card 0 skeleton 0
['0 recipes found']
['No recipes match your search', 'Clear filters']
--- /recipes-loading
card 0 skeleton 8
['Loading recipes…']
[]
สามอย่างที่ควรสังเกตในเฉลย:
col-span-fullทำให้กล่อง empty / error กินทุกคอลัมน์ของ grid · ถ้าไม่ใส่ ข้อความจะอัดอยู่ในช่องแรกช่องเดียว เพราะมันเป็นลูกของ grid เหมือนการ์ด (RecipeList คืน Fragment<>ไม่ได้ห่อ grid ของตัวเอง — grid อยู่ที่page.tsxตามสไลด์หน้า 49)- skeleton ใช้ key จาก index ได้ — ขัดกับหัวข้อ key ไหม? ไม่ขัด · skeleton ทุกช่องเหมือนกัน ไม่มี state ข้างใน และไม่มีวันถูกเรียงใหม่ · กฎจริงคือ “อย่าใช้ index เมื่อรายการเปลี่ยนลำดับได้และลูกมี state” ไม่ใช่ “ห้ามใช้ index เด็ดขาด”
- ข้อความหัวมาจาก
stateตัวเดียวกับรายการ — ตัวเลขกับการ์ดจึงไม่มีทางขัดกันอีก (ต่างจาก8 recipesเหนือการ์ด 12 ใบในหน้า 48) · ส่วน “page 1 of 7” ตัดทิ้งไปก่อน จนกว่าจะมี pagination จริง
สังเกตด้วยว่า RecipeList ตอนนี้ไม่รู้แล้วว่าข้อมูลมาจากไหน รู้แค่ว่าได้ state อะไรมา ·
คนที่รู้คือ page.tsx ซึ่งเป็นที่ที่เหมาะจะเรียก API จริงในอนาคต ·
และ Link ของ next/link ใช้ใน Server Component ได้โดยไม่ต้องมี "use client"
(build ข้างบนผ่าน) — ต่างจากปุ่มที่ต้องมี onClick
Recap ทั้งกลุ่ม Next.js App — บท 79 ถึง 85
สไลด์ชุดนี้แบ่ง agenda ไว้ในหน้า 3 เป็นห้าช่วง (ช่วงที่ 5 “Recap” ไม่มีหน้าสไลด์จริง จบที่หน้า 50) · ตารางนี้จับคู่ช่วงของสไลด์กับบทเรียนในกลุ่มนี้
| บท · สไลด์ | เรื่อง | สิ่งเดียวที่ต้องจำให้ได้ |
|---|---|---|
| 79 · หน้า 4–8 | โครงโฟลเดอร์ — components กับ containers | components/ วาดอย่างเดียว · containers/ ประกอบตามงานธุรกิจ · เป็นแค่ข้อตกลงของทีม Next.js ไม่รู้จัก |
| 80 · หน้า 9–16 | shadcn/ui | shadcn ก็อปโค้ด component มาไว้ในโปรเจกต์เรา · “อย่าแก้ไฟล์ตรง ๆ ให้สร้างใหม่ตามแบบ shadcn” (หน้า 16) |
| 81 · หน้า 17–27 | ออกแบบด้วย AI · design tokens | ตัดสินใจเรื่องสี / ตัวอักษร / ระยะ ครั้งเดียวเป็นตัวแปรใน globals.css
แล้วทุก component อ้างชื่อตัวแปร ไม่ใช่รหัสสี |
| 82 · หน้า 28–32 | วิเคราะห์ Button · CVA · compound variants | แยก “แบบของปุ่ม” เป็นแกน (variant · color · size) แล้วให้ cva ประกอบ class ให้แบบมี type |
| 83 · หน้า 33–40 | Web App Layout — Navbar · Container · Footer | ของที่อยู่ทุกหน้าไปอยู่ใน app/layout.tsx ครั้งเดียว · Container คุมความกว้างให้ทุกส่วนตรงแนวกัน |
| 84 · หน้า 41–46 | Recipe List Page — 4 สถานะ · วิเคราะห์ RecipeCard | ออกแบบหน้าจอทุกสถานะ (Default · Empty · Loading · Error) ก่อนเขียน ไม่ใช่แค่สถานะที่มีข้อมูล |
| 85 · หน้า 47–50 | หน้า All Recipes — mock แล้วแยก container | key จาก id · "use client" ใส่เมื่อมีเหตุผลที่ build บังคับ ·
ของ hardcode จาก mock ต้องรู้ว่าอยู่ตรงไหน |
ลองตอบดู
โค้ดสไลด์หน้า 48 ใช้ key={`recipe-${index}`} — รัน next dev แล้วเปิดหน้า จะเห็นอะไรใน terminal?
ตัวเลือกที่ 3 ถูก · ผมเปิด next dev แล้ว curl หน้า /recipes-v1 (โค้ดหน้า 48) สองรอบ
log มีแค่บรรทัด GET /recipes-v1 200 ไม่มี warning · eslint ก็ exit 0 ·
warning ขึ้นเฉพาะหน้าที่ลบ key ทิ้ง
ตัวเลือกที่ 1 ผิด — React และ eslint ไม่มีทางรู้ว่า key มาจาก index
มันเห็นแค่สตริง recipe-0, recipe-1 ที่ไม่ซ้ำกัน ·
ตัวเลือกที่ 2 คือข้อความตอนไม่ใส่ key ไม่ใช่ตอนใส่เป็น index ·
ตัวเลือกที่ 4 ผิด — key รับ string อะไรก็ได้ build ผ่านจริง
จุดที่ต้องจำ: key จาก index เป็นบั๊กที่ไม่มีเครื่องมือตัวไหนฟ้อง ต้องจับเองตอนอ่านโค้ด
RecipeCard ติด "use client" อยู่แล้วแบบบท 84 · ลบ "use client" ออกจาก RecipeList ของสไลด์หน้า 49 แล้ว build ใหม่ — ผลวัดจริงคืออะไร?
ตัวเลือกที่ 1 ถูก · ผลวัดจริงกรณี D → C: รวม HTML + JS หลัง gzip 189,287 → 189,044 ไบต์ ต่างกัน 243 ไบต์ (วัดกับ RecipeCard ตัวแทนในบทนี้)
ตัวเลือกที่ 2 ผิด — โฟลเดอร์ containers/ เป็นแค่ข้อตกลงของทีม
Next.js ไม่รู้จักมันเลย · ตัวเลือกที่ 3 ผิด — ทั้ง 4 แบบที่วัดขึ้น ○ /recipes เหมือนกัน ·
ตัวเลือกที่ 4 ผิด — ไฟล์ JS ไม่มีชื่อเมนูแล้วก็จริง แต่ข้อมูลย้ายไปอยู่ใน HTML แทน
(เจอ Mushroom Risotto ใน HTML 2 ครั้ง) เพราะการ์ดที่เป็น client ต้องได้ props ไปวาดใน browser
จุดที่ต้องจำ: "use client" ในไฟล์ที่ไม่มี hook / event ไม่จำเป็น ·
แต่ผลของการเอาออกขึ้นกับลูกของมัน — วัดทั้ง JS และ HTML ก่อนสรุปว่าคุ้ม
ตารางใช้ grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 — จอกว้าง 700px จะเห็นการ์ดแถวละกี่ใบ?
ตัวเลือกที่ 2 ถูก · CSS ที่ build ออกมาจริงคือ @media (min-width:40rem) สำหรับ sm
และ @media (min-width:64rem) สำหรับ lg · 40rem = 640px · 64rem = 1024px
ตัวเลือกที่ 1 และ 3 ผิดเพราะลำดับการเขียน class ใน className ไม่มีผล —
ตัวที่ชนะคือตัวที่ media query ตรงกับความกว้างจอ ·
ตัวเลือกที่ 4 ได้ตัวเลขถูกแต่เหตุผลผิดสองชั้น: sm ไม่ใช่ “เล็กกว่า” แต่เป็น “ตั้งแต่ขึ้นไป”
(min-width) และขอบของมันคือ 640px ไม่ใช่ 768px
จุดที่ต้องจำ: Tailwind เป็น mobile-first · prefix แปลว่า “ตั้งแต่ขนาดนี้ขึ้นไป”
รายการใช้ key={`recipe-${index}`} · กดหัวใจเมนูแรก (Carbonara) แล้วลบเมนูแรกออก — หัวใจทึบไปอยู่ที่ไหน?
ตัวเลือกที่ 4 ถูก · ผลจากการกดจริงใน browser: ♥ Avocado Toast with Poached Egg
ตัวเลือกที่ 1 คือสิ่งที่ควรเกิด และเกิดจริงในชุดที่ใช้ key={recipe.id} ·
ตัวเลือกที่ 2 ผิดทิศ — React มองว่า key recipe-2 (ใบท้าย) หายไป
state ของ recipe-0 จึงอยู่ที่ตำแหน่งแรกต่อ ไม่ได้ย้ายไปไหน ·
ตัวเลือกที่ 3 ผิด — ไม่มี error และไม่มี warning ใด ๆ ทั้ง console และ terminal
จุดที่ต้องจำ: state ของลูกผูกกับ key · key ผูกกับตำแหน่ง = state ผูกกับตำแหน่ง
ถามกลับได้เลย
ถ้าคุณวาง RecipeList ไว้ที่อื่น (เช่นใน app/recipes/ ข้างหน้า page ตามเคล็ดลับหน้า 8
ที่บอกว่า component ใน app/ ใช้แค่ในหน้านั้น) — ไม่ผิด เอามาคุยกันได้ว่าเลือกเพราะอะไร
ถ้าวัดขนาด JS ของโปรเจกต์คุณเองแล้วได้ตัวเลขต่างจากในบทนี้ นั่นปกติ เพราะ RecipeCard ของแต่ละคนไม่เหมือนกัน —
เอาตัวเลขทั้งสองแบบมาเทียบให้ดู ·
และถ้าอยากทำสถานะ Error ต่อ ให้ถามก่อนว่า “error มาจากไหน” เพราะตอนนี้ข้อมูลยังเป็น mock ที่พังไม่เป็น
อ้างอิง
- React — Rendering Lists — อ่านหัวข้อ Why does React need keys? และกล่อง Pitfall ที่พูดถึงการใช้ index เป็น key ตรงกับผลการทดลองกดหัวใจในบทนี้
- React — Preserving and Resetting State — อ่านหัวข้อ State is tied to a position in the render tree อธิบายว่าทำไม state หัวใจ “ติดตำแหน่ง” เมื่อ key ผูกกับ index
- Next.js — Server and Client Components — อ่านหัวข้อ When to use Server and Client Components? และ Reducing JS bundle size เป็นหลักการเดียวกับที่ผลวัดขนาด JS ในบทนี้ยืนยัน
- Tailwind CSS — Responsive design —
อ่านตาราง breakpoint ในหัวข้อ Overview และหัวข้อ Working mobile-first
ยืนยันค่า
sm= 40rem ·lg= 64rem - MDN —
Array.from()— อ่านหัวข้อ Using arrow functions and Array.from() และ Sequence generator (range) ที่ใช้{ length: 5 }ที่มาของท่าArray.from({ length: 12 })ในสไลด์หน้า 48