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

เขียน component แรก และส่งค่าเข้าไปด้วย props

~25 นาที · ควรผ่าน บทเรียน 70 (โครงโฟลเดอร์) มาก่อน

บทที่แล้วคุณได้โปรเจกต์เปล่า ๆ ที่รันขึ้นแล้ว แต่ยังไม่ได้เขียนอะไรเป็นของตัวเองเลย
บทนี้เราจะเขียน component แรก — ปุ่มหนึ่งปุ่ม — ตามโค้ดในสไลด์หน้า 22 ทุกตัวอักษร แล้วเอาไปใช้ในหน้าเว็บจริง
โค้ดในสไลด์สั้นแค่ 8 บรรทัด แต่มีของอยู่สี่อย่างที่ถ้าไม่เข้าใจจะเจ็บทีหลัง: children ที่โผล่มาเฉย ๆ โดยไม่มีใครประกาศ · จุดสามจุด {...restProps} ที่ทำให้ปุ่มรับ onClick ได้ฟรี · ไฟล์ index.ts ที่หลายคนคิดว่าเป็นเวทมนตร์ · และตัวอักษรตัวแรกของชื่อ component
ข้อสุดท้ายนี่แหละที่อันตรายที่สุด เพราะเขียนผิดแล้ว TypeScript ไม่ฟ้อง · build ผ่าน · หน้าเว็บไม่พัง แต่ผลลัพธ์ผิด — ผมรันจริงทั้งสองแบบแล้ว curl HTML ที่ server ส่งออกมาจริง ๆ มาวางเทียบให้ดูในหัวข้อ 5

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

# npx next build
▲ Next.js 16.3.4 (Turbopack)
✓ Compiled successfully
  Running TypeScript ...
  Finished TypeScript ...
✓ Generating static pages ...

# เวอร์ชันจริงบนเครื่องที่ใช้ตรวจ
next        16.3.4
react       19.2.8
typescript  5.9.3
node        v24.14.0

ทุกกล่องที่เขียนว่า "ผลรันจริง" ในบทนี้มาจากเครื่องจริง ไม่ได้พิมพ์เอง —
· ข้อความ error ทุกบรรทัดและเลข TSxxxx ก็อปมาจาก npx tsc --noEmit ตัวจริง
· HTML ที่โชว์ในหัวข้อ 2, 3, 5 และ 6 ได้มาจากการ curl ไปที่ server จริง แล้วตัดเฉพาะส่วนใน <main>
· โค้ดทุกก้อนที่ไม่ได้ติดป้ายว่า "พัง" ผ่าน npx next build จริง

เรื่องเดียวที่ถูกจัดให้เรียบร้อยก่อนเอามาโชว์ คือ ชื่อ path · โปรเจกต์ที่ใช้ตรวจวางหน้าทดสอบไว้ใต้โฟลเดอร์ย่อย (เช่น app/a71/…) เพราะมีบทอื่นใช้โปรเจกต์เดียวกันอยู่ · ในบทนี้ผมเขียน path กลับให้เป็นแบบที่คุณจะเห็นในโปรเจกต์ของคุณเอง (app/page.tsx · http://localhost:3000/) จะได้ไม่ต้องแปลในหัว — ตัวเลข บรรทัด คอลัมน์ ข้อความ error และ HTML ที่ออกมา ไม่ได้แก้แม้แต่ตัวเดียว

1 · Button ตัวแรก — component คือฟังก์ชันที่คืนหน้าตา

คุณเขียน Go มาแล้ว เพราะฉะนั้นประโยคนี้จะเข้าใจง่าย: component คือฟังก์ชันธรรมดา ๆ ตัวหนึ่ง
ไม่ได้พิเศษกว่าฟังก์ชันที่คุณเขียนใน Go ตรงไหนเลย มันแค่มีข้อตกลงสองข้อ:

  1. รับ argument ได้ตัวเดียว — เป็น object ก้อนเดียวที่ทุกคนเรียกมันว่า props
  2. คืนหน้าตาออกมา — ไม่ใช่ string ไม่ใช่ int แต่เป็นก้อน JSX ที่หน้าตาเหมือน HTML

จบแล้ว นั่นคือทั้งหมดของคำว่า component · ทีนี้มาดูของจริง — นี่คือ สไลด์หน้า 22 ทุกตัวอักษร วางไว้ที่ components/Bases/Button.tsx:

// components/Bases/Button.tsx
export type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement>;

const Button = (props: ButtonProps) => {
  const { children, ...restProps } = props;
  return <button {...restProps}>{children}</button>;
};

export default Button;

อ่านทีละบรรทัดแบบภาษาคน:

บรรทัดอ่านว่าอะไร
export type ButtonProps = … ตั้งชื่อให้ "รายการของที่ปุ่มนี้รับได้" ว่า ButtonProps · โดยไม่เขียนเองสักช่อง แต่ไปยืมรายการทั้งใบของปุ่ม HTML จริงมาใช้
const Button = (props: ButtonProps) => { ฟังก์ชันชื่อ Button รับของก้อนเดียวชื่อ props · ตัว B ใหญ่ ไม่ใช่เรื่องความสวยงาม — หัวข้อ 5 จะพิสูจน์ให้ดู
const { children, ...restProps } = props; แกะ props ออก: หยิบช่องชื่อ children ออกมาเก็บไว้ต่างหาก · ที่เหลือทั้งหมดกองรวมไว้ในกล่องชื่อ restProps
return <button {...restProps}>{children}</button>; คืนปุ่ม HTML จริงหนึ่งปุ่ม · เท restProps ทั้งกล่องใส่ลงไปเป็น attribute · แล้วเอา children ไปวางไว้ตรงกลาง
export default Button; ไฟล์นี้ส่งออกของหลักคือ Button · ที่อื่นจะ import ไปใช้ได้

มีเรื่องหนึ่งที่สไลด์ไม่ได้พูดแต่คนอ่านจะสะดุด: ไฟล์นี้ไม่มี import React เลย แล้วทำไมเขียน React.ButtonHTMLAttributes ได้?
คำตอบคือ @types/react ประกาศชื่อ React ไว้เป็น namespace ระดับ global ให้แล้ว — มันเป็นแค่ "ชื่อของกล่อง type" ไม่ใช่ตัวแปรที่ต้องมีอยู่ตอนรัน จึงไม่ต้อง import
ผมลบ import React ออกแล้ว build จริง ผ่านสนิท:

$ npx tsc --noEmit
# (ไม่มี output — แปลว่าไม่มี error) exit code = 0
🧰 เทียบให้เห็นภาพ — แม่พิมพ์ขนมกับขนมที่ปั๊มออกมา

ไฟล์ Button.tsx คือแม่พิมพ์ — คุณทำไว้อันเดียว แล้วปั๊มขนมออกมากี่ชิ้นก็ได้ ชิ้นละหน้าตาเหมือนกัน
เวลาเขียน <Button>บันทึก</Button> ในหน้าเว็บ นั่นคือการปั๊มขนมหนึ่งชิ้น ไม่ใช่การเรียกแม่พิมพ์มาวางบนโต๊ะ
แม่พิมพ์อยู่ที่เดิมของมันเสมอ ปั๊มสิบชิ้นก็ยังใช้แม่พิมพ์อันเดิม

ที่ไม่เหมือนกัน: แม่พิมพ์ขนมจริงปั๊มออกมาได้แบบเดียวตายตัว อยากได้ขนมรสอื่นต้องไปทำแม่พิมพ์ใหม่
แต่ component รับคำสั่งได้ตอนปั๊ม — <Button disabled> กับ <Button type="submit"> ใช้แม่พิมพ์อันเดียวกัน แต่ได้ของคนละแบบ ของที่ยื่นเข้าไปตอนปั๊มนั่นแหละคือ props ทั้งบทนี้พูดเรื่องนี้เรื่องเดียว

🗣 ศัพท์ที่เพิ่งเรียนไป
component
ฟังก์ชันที่รับ props แล้วคืน UI ออกมา · ในโปรเจกต์นี้เขียนเป็นไฟล์ .tsx หนึ่งไฟล์ต่อหนึ่งตัว · ถาม senior ได้ว่า “ที่นี่แบ่ง component ยังไง อะไรควรอยู่ใน components/ อะไรควรอยู่ใน app/” คำตอบต่างกันทุกทีม และมักไม่ได้เขียนไว้ที่ไหน
props
ย่อมาจาก properties · คือ object ก้อนเดียวที่ component รับเข้ามา · props เป็นของอ่านอย่างเดียว (read-only) ห้ามแก้ค่าในนั้น — พิมพ์บอก AI ได้ว่า “extract these props into a typed props object” ตรงกว่าการบอกว่า “ทำให้ component รับค่าได้”
JSX / TSX
JSX คือไวยากรณ์ที่ให้เขียนอะไรที่หน้าตาเหมือน HTML ปนอยู่ในโค้ด JavaScript · TSX คือ JSX เวอร์ชันที่อยู่ในไฟล์ TypeScript · เพราะฉะนั้นไฟล์ที่มี JSX ต้องนามสกุล .tsx ไม่ใช่ .ts — จำข้อนี้ไว้ จะได้ไม่งงตอนหัวข้อ 4
React.ButtonHTMLAttributes<HTMLButtonElement>
type สำเร็จรูปที่แปลว่า “ทุก attribute ที่ปุ่ม HTML จริงรับได้” — onClick, disabled, type, className, title … ครบทั้งชุด · ถาม AI ได้ว่า “what does React.ButtonHTMLAttributes include” แล้วให้มันไล่ให้ดู

2 · children — ของที่ยัดไว้ระหว่างแท็กเปิดกับแท็กปิด

คำถามที่คนอ่านสไลด์หน้า 22 ถามกันมากที่สุดคือ “children โผล่มาจากไหน ไม่เห็นมีใครประกาศ”

คำตอบสั้น ๆ: คุณเป็นคนใส่มันเองตอนใช้งาน
เวลาเขียน <Button>กดฉันสิ</Button> ข้อความ กดฉันสิ ที่อยู่ระหว่างแท็กเปิดกับแท็กปิด ถูกยัดเข้าไปใน props ให้อัตโนมัติ ในช่องชื่อ children

พูดอีกแบบ — สองบรรทัดนี้แปลว่าเรื่องเดียวกันเป๊ะ:

ที่คุณเขียน

<Button type="button">กดฉันสิ</Button>

แบบที่มนุษย์อ่านสบายตา

ที่ React เห็นจริง ๆ

props = {
  type: "button",
  children: "กดฉันสิ"
}

children เป็นแค่ช่องหนึ่งใน object ไม่ต่างจาก type

ส่วนฝั่ง type — children ไม่ได้มาจากอากาศเหมือนกัน มันติดมากับ React.ButtonHTMLAttributes<HTMLButtonElement> เพราะ type ตัวนั้นสืบทอดมาจาก type ที่มีช่อง children?: React.ReactNode อยู่แล้ว
เพราะฉะนั้นในสไลด์หน้า 22 ไม่มีใครต้องเขียน children ลงใน type เลย — มันมาให้ฟรี

แล้วถ้า ลืมเอา children ไปวาง จะเป็นยังไง? ตรงนี้คือกับดักจริงที่มือใหม่เจอกันเยอะ เพราะ มันไม่ error

// components/NoChild.tsx — สังเกตว่า return ไม่มี {children}
const NoChild = (props: NoChildProps) => {
  const { children, ...restProps } = props;
  return <button {...restProps}></button>;
};

// ใช้แบบนี้
<NoChild type="button">กดฉันสิ</NoChild>

npx tsc --noEmit จะฟ้องไหม? แล้ว HTML ที่ออกมาหน้าตายังไง?

tsc ไม่ฟ้องเลย · exit code = 0 และ HTML ที่ curl ได้จริงคือ:

$ curl -s http://localhost:3000/
<button type="button"></button>

ได้ปุ่มเปล่า — type="button" ไปถึงเรียบร้อย แต่คำว่า "กดฉันสิ" หายไปเฉย ๆ

ทำไม tsc ถึงไม่ฟ้อง? เพราะคุณแกะ children ออกมาแล้วจริง ๆ แค่ไม่ได้เอาไปใช้ต่อ ซึ่งในสายตา TypeScript ไม่ผิดอะไร · บทเรียนคือ “ไม่มี error” ไม่ได้แปลว่า “ถูก” ต้องเปิดดูหน้าเว็บจริงหรือ curl ดู HTML จริงเสมอ

ในโค้ด <Button className="wide">ตกลง</Button> ค่าอะไรจะไปอยู่ในช่อง props.children?

ตัวเลือกที่ 2 ถูก · กติกาแบ่งง่ายมาก — ของที่เขียนใน "หัว" ของแท็ก (ก่อน >) คือ props ปกติ ส่วนของที่อยู่ "ระหว่าง" แท็กเปิดกับแท็กปิด คือ children

ตัวเลือกที่ 4 คือความเข้าใจผิดที่พบบ่อยที่สุด — children ติดมากับ React.ButtonHTMLAttributes อยู่แล้ว ไม่ต้องประกาศเอง

3 · {...restProps} — ยกกล่องที่เหลือเทใส่ปุ่มจริง

จุดสามจุดในโค้ดสไลด์หน้า 22 ปรากฏสองที่ และทำคนละหน้าที่กัน ตรงนี้เป็นจุดที่คนสับสนบ่อย เลยแยกให้ดูชัด ๆ:

ตำแหน่งเรียกว่าอะไรทำอะไร
const { children, ...restProps } = props rest เก็บ — "ที่เหลือทั้งหมดยัดใส่กล่องนี้"
<button {...restProps}> spread เท — "แกะกล่องนี้ออก แล้วโปรยของทุกชิ้นลงตรงนี้"

ผลของสองบรรทัดนี้คือ ปุ่มของคุณรับ attribute ของปุ่ม HTML ได้ครบทุกตัวโดยไม่ต้องเขียนเพิ่มสักบรรทัด
ผมเขียนหน้าเว็บที่ใช้ Button จากสไลด์หน้า 22 ตรง ๆ:

// app/page.tsx
import { Button } from "@/components/Bases";

export default function Page() {
  return (
    <main>
      <h1>บท 71</h1>
      <Button type="button" title="ปุ่มแรก">กดฉันสิ</Button>
      <Button disabled>ปุ่มที่กดไม่ได้</Button>
    </main>
  );
}

ผลรันจริง — HTML ที่ server ส่งออกมา (จัดบรรทัดใหม่ให้อ่านง่าย เนื้อหาไม่แก้):

$ curl -s http://localhost:3000/
<main>
  <h1>บท 71</h1>
  <button type="button" title="ปุ่มแรก">กดฉันสิ</button>
  <button disabled="">ปุ่มที่กดไม่ได้</button>
</main>

สังเกตว่า type, title, disabled ไปถึงปุ่มจริงครบทุกตัว ทั้งที่ในไฟล์ Button.tsx ไม่มีคำว่า type หรือ disabled เขียนอยู่เลยแม้แต่คำเดียว — นั่นคือของฟรีที่ {...restProps} แจกให้

แล้วมันฟรีจริงหรือแค่ปล่อยผ่านทุกอย่าง? ลองส่งของที่ปุ่ม HTML ไม่รู้จักดู:

// ส่ง variant ซึ่งไม่มีใน ButtonHTMLAttributes
<Button variant="outlined">กดฉันสิ</Button>

ผลรันจริง — npx tsc --noEmit หยุดทันที:

app/page.tsx(6,15): error TS2322: Type '{ children: string; variant: string; }'
  is not assignable to type 'IntrinsicAttributes & ButtonProps'.
  Property 'variant' does not exist on type 'IntrinsicAttributes & ButtonProps'.

ดีมาก — แปลว่า ButtonProps ไม่ได้เปิดรับทุกอย่างมั่ว ๆ มันรับเฉพาะที่ปุ่ม HTML จริงรับได้เท่านั้น · ถ้าอยากเพิ่มของของตัวเองต้องประกาศ (หัวข้อ 6)

🧰 เทียบให้เห็นภาพ — พนักงานรับพัสดุที่หน้าตึก

คุณสั่งของออนไลน์ส่งมาที่ออฟฟิศ พัสดุมาถึงเป็นกล่องรวมหนึ่งกล่อง พนักงานหน้าตึกเปิดกล่อง หยิบเอกสารที่จ่าหน้าถึงเขาเองออกไปหนึ่งซอง (นั่นคือ children) แล้วยกที่เหลือทั้งกล่องส่งต่อขึ้นไปชั้นบนโดยไม่เปิดดู (นั่นคือ {...restProps})
ข้อดีคือ วันไหนบริษัทเริ่มสั่งของชนิดใหม่ที่ไม่เคยมีมาก่อน พนักงานหน้าตึกไม่ต้องเรียนรู้อะไรใหม่เลย — เขาแค่ส่งต่อเหมือนเดิม

ที่ไม่เหมือนกัน: พนักงานจริงส่งต่ออะไรก็ได้ที่เข้ามาในกล่อง ของแปลกปลอมก็ผ่านไปได้
แต่ ButtonProps มียามอยู่หน้าประตูอีกชั้น — ของที่ปุ่ม HTML ไม่รู้จักอย่าง variant ถูกตีกลับตั้งแต่ตอน compile ด้วย TS2322 ยังไม่ทันเข้ากล่องด้วยซ้ำ

🗣 ศัพท์ที่เพิ่งเรียนไป
rest / spread
สัญลักษณ์เดียวกัน (...) แต่คนละหน้าที่ · อยู่ฝั่งซ้ายของ = หรือใน pattern การแกะ = rest (เก็บที่เหลือ) · อยู่ในที่ที่กำลังสร้างของใหม่ = spread (โปรยออก) · ถาม AI ได้ว่า “is this dots a rest or a spread here” ถ้าอ่านโค้ดคนอื่นแล้วไม่แน่ใจ
prop spreading
ท่าที่เขียน <button {...restProps}> เพื่อส่ง props ต่อเป็นชุด · ถาม senior ได้ว่า “ทีมนี้ยอมให้ spread props ลง DOM element ไหม” — บางทีมห้าม เพราะทำให้ไล่ไม่ออกว่า attribute ไหนมาจากไหน และมี eslint rule react/jsx-props-no-spreading ไว้ปิดโดยเฉพาะ
destructuring
การแกะ object ออกเป็นตัวแปรย่อย ๆ ในบรรทัดเดียว (const { children } = props) · ในโลก Go ที่คุณคุ้นเคย ไม่มีท่านี้ ต้องเขียน props.Children ทุกครั้ง — ผลลัพธ์เหมือนกัน ต่างแค่ความสั้น

4 · index.ts แบบ barrel — ทางเข้าเดียวของทั้งโฟลเดอร์

สไลด์หน้า 23 เพิ่มไฟล์ที่สองเข้ามา ชื่อ components/Bases/index.ts พร้อมคำอธิบายบรรทัดเดียวว่า “To import and re-export components” · โค้ดเต็มคือ:

// components/Bases/index.ts  — สังเกตนามสกุล .ts ไม่ใช่ .tsx เพราะไม่มี JSX ในไฟล์นี้
import Button, { type ButtonProps } from "./Button";

export type { ButtonProps };
export { Button };

ไฟล์นี้ไม่มีตรรกะอะไรเลย — มันแค่รับของเข้ามาจากไฟล์ข้าง ๆ แล้วส่งออกไปใหม่
ประโยชน์อยู่ที่ฝั่งคนใช้: แทนที่จะต้องรู้ชื่อไฟล์ทุกไฟล์ในโฟลเดอร์ ก็ import จากชื่อโฟลเดอร์เฉย ๆ ได้

ไม่มี index.ts — ต้องรู้ชื่อไฟล์ทุกไฟล์

import Button from "@/components/Bases/Button";
import Input  from "@/components/Bases/Input";
import Card   from "@/components/Bases/Card";

วันไหนย้าย Button.tsx ไปโฟลเดอร์อื่น ต้องไล่แก้ทุกไฟล์ที่ import

มี index.ts — จำแค่ชื่อโฟลเดอร์

import { Button, Input, Card }
  from "@/components/Bases";

ย้ายไฟล์เมื่อไหร่ แก้ที่ index.ts ที่เดียว คนใช้ไม่รู้เรื่องด้วย

แล้วมันเป็นเวทมนตร์หรือเปล่า? ไม่ใช่ · มีกฎธรรมดา ๆ อยู่สองข้อที่ทำให้มันทำงาน:

  1. ชื่อ index เป็นข้อตกลงของตัวหาไฟล์ (module resolver) ว่า “ถ้าชี้มาที่โฟลเดอร์ ให้เปิดไฟล์ชื่อ index ในนั้น” ไม่มีอะไรพิเศษกว่านั้น
  2. @/ ไม่ใช่ของ Next.js — มันคือ path alias ที่ตั้งไว้ใน tsconfig.json ของโปรเจกต์นี้จริง ๆ ว่า "paths": { "@/*": ["./*"] } · เปิดไฟล์ดูได้เลย

พิสูจน์ว่าไม่ใช่เวทมนตร์แบบง่ายที่สุด — ผมเปลี่ยนชื่อไฟล์ index.ts ทิ้งไปชั่วคราว โดยไม่แตะไฟล์อื่นเลย แล้วรัน tsc:

app/page.tsx(1,24): error TS2307: Cannot find module '@/components/Bases'
  or its corresponding type declarations.

พอเอาไฟล์กลับมา error หายทันที exit code กลับเป็น 0 · แปลว่า index.ts เป็นแค่ไฟล์ธรรมดาไฟล์หนึ่ง ที่บังเอิญตั้งชื่อตรงกับข้อตกลง

⚠️ คำว่า type ในไฟล์นี้มีสองที่ — และกฎจริงไม่ใช่ "ต้องมีทั้งสองที่"

สไลด์หน้า 23 เขียนคำว่า type ไว้สองแห่ง คือตอน import ({ type ButtonProps }) และตอน export (export type { … }) · ตำราส่วนใหญ่จะบอกว่า “ลบไม่ได้นะ เดี๋ยว TS1205” · ผมลองลบทีละที่แล้วรัน npx tsc --noEmit จริง ผลไม่ตรงกับที่ตำราบอก

บรรทัด importบรรทัด exportผลจริงจาก npx tsc --noEmit
{ type ButtonProps } export type { … } ✅ ผ่าน (exit 0) — แบบที่สไลด์เขียน
{ type ButtonProps } export { … } ✅ ผ่าน (exit 0) — ไม่ error!
{ ButtonProps } export type { … } ✅ ผ่าน (exit 0)
{ ButtonProps } export { … } ❌ TS1205

กฎจริงคือ “ต้องมีอย่างน้อยหนึ่งที่” ไม่ใช่ “ต้องมีทั้งสองที่” · แถวสุดท้ายคือแถวเดียวที่พัง — ลบคำว่า type ออกจากทั้งสองบรรทัด:

// components/Bases/index.ts — ไม่มีคำว่า type เลยสักที่
import Button, { ButtonProps } from "./Button";

export { ButtonProps };
export { Button };
components/Bases/index.ts(3,10): error TS1205: Re-exporting a type when
  'isolatedModules' is enabled requires using 'export type'.

เหตุผลอ่านง่าย ๆ: ButtonProps เป็น type ล้วน ๆ ที่หายไปหมดตอนแปลงเป็น JavaScript ไม่เหลือค่าจริงให้ export · โปรเจกต์ Next.js เปิด "isolatedModules": true ไว้ใน tsconfig.json (เปิดไฟล์ดูได้) ซึ่งแปลว่า ตัวแปลงต้องอ่านไฟล์นี้ไฟล์เดียวแล้วตัดสินใจให้ได้ ว่าบรรทัดไหนต้องลบทิ้ง โดยห้ามเปิด Button.tsx ไปดู
ถ้าบรรทัด import เขียนว่า { type ButtonProps } ไว้แล้ว ตัวแปลงก็รู้ตั้งแต่บรรทัดแรกแล้วว่านี่คือ type — บรรทัด export ไม่ต้องบอกซ้ำ ก็เลยไม่ error
แต่ถ้าไม่มีที่ไหนบอกเลย มันเดาไม่ออก จึงฟ้องให้เขียนให้ชัด

แล้วสรุปควรเขียนยังไง? — เขียนทั้งสองที่ตามสไลด์ไปเลย · ไม่ใช่เพราะ compiler บังคับ แต่เพราะมันกันอนาคต: วันที่ใครไปแก้บรรทัด import (หรือโปรเจกต์เปิด verbatimModuleSyntax เพิ่ม) ไฟล์นี้จะยังไม่พัง · นี่คือ "ทางที่ปลอดภัย" ไม่ใช่ "ข้อบังคับ" — และการรู้ว่ามันต่างกันตรงไหน คือสิ่งที่ทำให้คุณไม่ต้องท่องกฎเป็นนกแก้ว

🧰 เทียบให้เห็นภาพ — เคาน์เตอร์ประชาสัมพันธ์หน้าตึก

ตึกออฟฟิศมีหลายห้อง ถ้าไม่มีเคาน์เตอร์ประชาสัมพันธ์ คนมาติดต่อต้องจำเลขห้องเองทุกครั้ง ("ฝ่ายบัญชี ห้อง 512") วันไหนฝ่ายบัญชีย้ายห้อง ทุกคนที่จำเลขเดิมไว้ต้องมาเรียนใหม่หมด
index.ts คือเคาน์เตอร์ประชาสัมพันธ์ — คุณเดินเข้าตึกแล้วบอกว่า "ขอพบฝ่ายบัญชี" พอ เคาน์เตอร์จัดการเรื่องเลขห้องให้

ที่ไม่เหมือนกัน: เคาน์เตอร์จริงมีคนนั่ง มีค่าจ้าง มีเวลาเปิดปิด
แต่ index.ts ไม่เหลืออะไรตอนโปรแกรมทำงานจริง — เป็นแค่ป้ายบอกทางตอน compile · ราคาที่จ่ายไม่ใช่ความช้าตอนรัน แต่คือต้องมีคนคอยอัปเดตป้าย ลืมเติมชื่อ component ใหม่ลงไป คนอื่นก็ import ไม่เจอ

🗣 ศัพท์ที่เพิ่งเรียนไป
barrel file
ไฟล์ index.ts ที่ไม่ทำอะไรนอกจากรวบของจากไฟล์ข้าง ๆ มาส่งออกใหม่ · ถาม senior ได้ว่า “ที่นี่ใช้ barrel ไหม และใช้ถึงระดับไหน” — บางทีมเลิกใช้เพราะทำให้ bundler ตัดโค้ดที่ไม่ได้ใช้ทิ้งยากขึ้น
re-export
การส่งออกของที่ตัวเองไม่ได้สร้าง แต่ import มาจากที่อื่น · เขียนย่อได้เป็น export { Button } from "./Button"; ในบรรทัดเดียวก็ได้ · สไลด์เลือกเขียนแบบสองบรรทัด (import ก่อน แล้วค่อย export) ซึ่งผลเหมือนกัน
path alias
ทางลัดที่ตั้งไว้ใน tsconfig.json ให้เขียน @/components/… แทน ../../components/… · เป็นของ TypeScript/bundler ไม่ใช่ของ Next.js · พิมพ์บอก AI ได้ว่า “convert these relative imports to the @/ alias”
isolatedModules
โหมดที่บังคับให้แต่ละไฟล์แปลได้ด้วยตัวเองโดยไม่ต้องเปิดไฟล์อื่นดู · เป็นเหตุผลที่ต้องมีคำว่า type กำกับอย่างน้อยหนึ่งที่ ในเส้นทางของ type ที่ถูกส่งต่อ · ถาม AI ได้ว่า “why does isolatedModules require export type, and when is it not required” — ต่อท้ายด้วยท่อนหลังด้วย ไม่งั้นมันจะตอบแค่กฎกว้าง ๆ ที่ไม่ตรงกับสิ่งที่คุณเจอจริง

5 · ตัวอักษรตัวแรกของชื่อ component เปลี่ยนความหมายทั้งหมด

ถึงส่วนที่สำคัญที่สุดของบทนี้แล้ว · ในโค้ดสไลด์หน้า 22 ชื่อ component คือ Button ตัว B ใหญ่ · หลายคนคิดว่าเป็นธรรมเนียมความสวยงาม เหมือนตั้งชื่อ struct ใน Go

ไม่ใช่ · มันคือกฎที่เปลี่ยนความหมายของโค้ด:

  <Button>   ตัวใหญ่  →  React อ่านว่า "เรียกฟังก์ชันชื่อ Button ที่อยู่ในไฟล์นี้"
  <button>   ตัวเล็ก  →  React อ่านว่า "สร้างแท็ก HTML ชื่อ button"
                          ↑
                    ไม่สนใจเลยว่าคุณจะประกาศ const button ไว้ข้างบนหรือเปล่า

ปัญหาคือ ถ้าคุณเผลอตั้งชื่อ component เป็นตัวเล็ก และชื่อนั้นดันไปตรงกับแท็ก HTML ที่มีอยู่จริง จะไม่มีใครเตือนคุณเลยสักคน · มาดูของจริง

// ไฟล์ A — ชื่อ component เป็นตัวเล็ก
const button = (props: { children: React.ReactNode }) => {
  return <span>ดาว ★ {props.children}</span>;
};

export default function Page() {
  return (
    <main>
      <button>สวัสดี</button>
    </main>
  );
}

หน้านี้จะขึ้น error ไหม? แล้ว HTML ที่ออกมาจะมีดาว ★ หรือเปล่า?

ไม่มี error เลยแม้แต่ตัวเดียว — npx tsc --noEmit ผ่าน (exit 0) · npx next build ผ่าน · หน้าเว็บเปิดได้ปกติ
แต่ HTML ที่ curl ได้จริงคือ:

$ curl -s http://localhost:3000/
<main><button>สวัสดี</button></main>

ไม่มีดาว · ไม่มี <span> — ฟังก์ชัน button ที่คุณเขียนไว้ไม่ถูกเรียกเลยสักครั้ง React มองว่า <button> คือแท็ก HTML และสร้างปุ่มธรรมดาให้

เทียบกับไฟล์เดียวกันเป๊ะ เปลี่ยนแค่ตัว b เป็น B:

$ curl -s http://localhost:3000/   # (ไฟล์เดิม เปลี่ยนแค่ b เป็น B)
<main><span>ดาว ★ <!-- -->สวัสดี</span></main>

คราวนี้ฟังก์ชันทำงาน · (<!-- --> ที่แทรกอยู่คือคั่นข้อความที่ React ใส่ให้เอง เพื่อให้ตอน hydrate แยกออกว่าข้อความสองก้อนไม่ได้ติดกัน — ไม่ใช่บั๊ก)

แล้วถ้าชื่อตัวเล็กที่ไม่ตรงกับแท็ก HTML ตัวไหนล่ะ? กรณีนั้นโชคดีหน่อย เพราะ TypeScript ฟ้อง · ผมลองตั้งชื่อว่า myButton:

app/page.tsx(8,7): error TS2339: Property 'myButton' does not exist
  on type 'JSX.IntrinsicElements'.
app/page.tsx(8,23): error TS2339: Property 'myButton' does not exist
  on type 'JSX.IntrinsicElements'.

คำว่า JSX.IntrinsicElements ในข้อความ error คือ "รายชื่อแท็ก HTML ที่มีอยู่จริง" · TypeScript กำลังบอกว่า "หาแท็กชื่อ myButton ในรายชื่อไม่เจอ" — มันไม่เคยคิดจะไปหาฟังก์ชันชื่อเดียวกันเลย เพราะตัวแรกเป็นตัวเล็ก

เขียนแบบนี้TypeScriptผลจริงที่ได้
<Button> ตัวใหญ่ ผ่าน ✅ เรียก component ของคุณ
<button> ตัวเล็ก + ชื่อตรงแท็ก HTML ผ่าน (ไม่ฟ้องอะไรเลย) ☠️ สร้างแท็ก HTML · ฟังก์ชันคุณถูกเมิน
<myButton> ตัวเล็ก + ไม่ตรงแท็กไหน TS2339 ❌ build ไม่ผ่าน — โชคดีที่พังตั้งแต่แรก

เพื่อนเขียน const card = (props) => … แล้วใช้ <card>เนื้อหา</card> · จะเกิดอะไรขึ้น?

ตัวเลือกที่ 4 ถูก · card ไม่ใช่แท็ก HTML จริง TypeScript จึงหาไม่เจอใน JSX.IntrinsicElements แล้วฟ้อง TS2339 ทันที — กรณีนี้คือกรณีที่โชคดี เพราะพังตั้งแต่ยังไม่ทันรัน

กรณีที่โชคร้ายกว่าคือชื่อที่ดันตรงกับแท็ก HTML จริงอย่าง button, form, section, header — พวกนี้ผ่านหมดทั้ง tsc และ build แล้วให้ผลลัพธ์ผิดเงียบ ๆ ตามที่กล่องเฉลยข้างบนแสดงไว้

6 · เพิ่ม props ของตัวเอง — และกับดักสองอันของ variant

ถึงตรงนี้ปุ่มของเรารับได้แค่ของที่ปุ่ม HTML รับได้ · สไลด์หน้า 30 แสดงภาพปุ่มสามแบบ (contained / outlined / text) แล้วหน้า 31 ก็โชว์โค้ดที่ทำให้มันเป็นจริง · ท่าคือเอา type สองก้อนมาบวกกัน:

type ButtonVariantTypes = "contained" | "outlined" | "text";

export type ButtonProps = {
  variant?: ButtonVariantTypes;
} & React.ButtonHTMLAttributes<HTMLButtonElement>;

เครื่องหมาย & อ่านว่า "และ" — "ของที่ปุ่มนี้รับได้ = ช่อง variant ของเรา บวกกับ ทุกอย่างที่ปุ่ม HTML รับได้"
ส่วน ? หลัง variant แปลว่า ไม่ส่งมาก็ได้

ทีนี้ในตัว component สไลด์แกะ props ออกมาสามอย่าง สังเกตบรรทัดนี้ให้ดี:

const { variant = "contained", children, className, ...restProps } = props;

มีของใหม่สองอย่างที่ต้องเข้าใจ:

และนี่คือเหตุผลที่ต้องดึง variant ออกด้วย — ถ้าปล่อยไว้ใน restProps แล้วเทลง <button> มันจะไหลไปโผล่บนหน้าเว็บจริง · ผมลองแล้ว:

ลืมดึง variant ออก

const { children, ...restProps } = props;
return <button {...restProps}>{children}</button>;

ผลรันจริง — HTML ที่ออกมา:

<button variant="outlined">รั่ว</button>

variant กลายเป็น attribute จริงบน DOM ที่ไม่มีความหมายอะไรเลย · React 19 ไม่เตือน ไม่ error — ปล่อยผ่านเงียบ ๆ (ผมเช็ค log ของ dev server แล้วไม่มี warning สักบรรทัด)

ดึง variant ออกก่อน

const { variant = "contained", children,
        className, ...restProps } = props;

ผลรันจริง — HTML ที่ออกมา:

<button class="btn btn-outlined wide">ขอบเส้น</button>

variant ทำงานเสร็จหน้าที่ในฝั่ง component แล้วไม่ตามไป DOM · เหลือแต่ class ที่มีความหมายจริง

⚠️ กับดักอันที่สอง — บั๊กที่อยู่ในโค้ดสไลด์หน้า 31 บรรทัดที่ 27 จริง ๆ

กับดักอันแรก (ลืมดึง variant ออก) สไลด์ไม่ได้พลาด — แกดึงออกถูกแล้ว · แต่บรรทัดถัดมาที่เอา class มาต่อกัน มีบั๊กจริงซ่อนอยู่ · สไลด์เขียนไว้แบบนี้:

// สไลด์หน้า 31 บรรทัด 25 และ 27
const variantStyles = getVariantStyles(variant);
...
<button className={`${variantStyles} ${className}`} {...restProps}>

อ่านผ่าน ๆ ดูไม่มีอะไรผิด แต่ผมรันจริงแล้ว curl ดู HTML ตอนที่คนใช้ไม่ส่ง className มา — ซึ่งเป็นกรณีปกติที่สุด:

// เขียน <Button>ค่าเริ่มต้น</Button> เฉย ๆ ไม่ส่ง className
<button class="btn btn-contained undefined">ค่าเริ่มต้น</button>

คำว่า undefined ติดไปเป็นชื่อ class จริง ๆ บนหน้าเว็บ · ไม่พังทันที แต่เละแน่ตอนที่มีคนเผลอเขียน CSS ชื่อ .undefined หรือตอนไล่อ่าน HTML ใน DevTools
สาเหตุ: template literal (` `) แปลงทุกอย่างเป็นข้อความ และ undefined แปลงเป็นข้อความได้ว่า "undefined"

วิธีแก้ที่ผมรันแล้วได้ผลสะอาด — กรองค่าว่างทิ้งก่อนต่อ:

const variantStyles = getVariantStyles(variant);
const merged = [variantStyles, className].filter(Boolean).join(" ");
return <button className={merged} {...restProps}>{children}</button>;

// ผลรันจริงหลังแก้:
// <button class="btn btn-contained">ค่าเริ่มต้น</button>
// <button class="btn btn-outlined wide">ขอบเส้น</button>

📌 หมายเหตุเรื่อง class ที่สไลด์ใช้ — โค้ดในสไลด์หน้า 31 คืน class อย่าง px-2.5 py-1.5 border-2 rounded-md ซึ่งเป็น Tailwind CSS ที่ยังไม่ได้สอนในบทนี้ · บทนี้ผมเลยเปลี่ยนเป็นชื่อ class ธรรมดา (btn btn-outlined) เพื่อให้โฟกัสอยู่ที่เรื่อง props อย่างเดียว — โครงสร้าง type และการแกะ props ยังเหมือนสไลด์ทุกตัวอักษร ส่วน Tailwind รอ บทเรียน 72

ทำไมต้องดึง variant ออกจาก props ก่อน แทนที่จะปล่อยให้อยู่ใน restProps?

ตัวเลือกที่ 1 ถูก · {...restProps} เทของทุกชิ้นลงบน <button> ตรง ๆ อะไรที่ไม่ได้ดึงออกก่อนก็ตามไปด้วยหมด

ตัวเลือกที่ 4 ผิด และนี่คือส่วนที่อันตราย — บทนี้รันจริงแล้วพบว่า React 19 ไม่ error ไม่ warning เลย แค่ปล่อย variant="outlined" ให้ไปนั่งอยู่บน DOM เงียบ ๆ · แปลว่าคุณจะไม่รู้ตัวจนกว่าจะเปิด DevTools ดูเอง

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

ลูกค้าสั่ง "ข้าวผัด ไม่ใส่ผัก โต๊ะ 5 จ่ายด้วยบัตร" · พนักงานเสิร์ฟต้องตัดข้อมูลการจ่ายเงินออกก่อนส่งใบสั่งเข้าครัว เพราะแม่ครัวไม่มีอะไรทำกับคำว่า "จ่ายด้วยบัตร"
variant คือ "จ่ายด้วยบัตร" — เป็นข้อมูลของพนักงานเสิร์ฟ ไม่ใช่ของแม่ครัว ส่วน disabled กับ type คือ "ไม่ใส่ผัก" ที่ต้องส่งต่อเข้าครัวจริง

ที่ไม่เหมือนกัน: แม่ครัวจริงเห็นคำว่า "จ่ายด้วยบัตร" ก็จะทักกลับมา ว่าใบสั่งนี้แปลก ๆ นะ
แต่ <button> ของ React 19 ไม่ทัก — มันเอาไปแปะบนหน้าเว็บให้เฉย ๆ แล้วปล่อยให้คุณไปเจอเองทีหลัง เพราะฉะนั้นต้องเป็นคนตัดออกเอง ไม่มีใครช่วยตรวจให้

🗣 ศัพท์ที่เพิ่งเรียนไป
intersection type (&)
เอา type สองก้อนมารวมเป็นก้อนเดียวที่ต้องมีครบทั้งสองฝั่ง · อย่าสับสนกับ | (union) ที่แปลว่า "อย่างใดอย่างหนึ่ง" · พิมพ์บอก AI ได้ว่า “extend this props type with the native button attributes”
default parameter value
ค่าตั้งต้นที่เขียนไว้ตอน destructure (variant = "contained") · ทำงานเฉพาะตอนค่าเป็น undefined เท่านั้น — ถ้าส่ง null หรือ "" มา ค่าตั้งต้นไม่ทำงาน
variant / design token
variant คือชื่อที่วงการ UI ใช้เรียก "หน้าตาแบบหนึ่งของ component เดียวกัน" · สไลด์หน้า 30 ตั้งชื่อไว้ว่า contained / outlined / text ซึ่งเป็นชุดเดียวกับ Material Design · ถาม senior ได้ว่า “ทีมนี้เอาชื่อ variant มาจาก design system ตัวไหน” เพราะชื่อพวกนี้ควรตรงกับที่ดีไซเนอร์ใช้ใน Figma ไม่ใช่ตั้งเอง

ไฟล์ barrel เขียนบรรทัด import ไว้ว่า import Button, { type ButtonProps } from "./Button"; (แบบสไลด์) · แล้วคุณลบคำว่า type ออกจากบรรทัด export ให้เหลือ export { ButtonProps }; · npx tsc --noEmit จะว่ายังไง?

ตัวเลือกที่ 4 ถูก · บทนี้ลองจริงทั้งสี่แบบแล้ว (ตารางในหัวข้อ 4) — แบบนี้ tsc ไม่ฟ้องอะไรเลย exit 0

เหตุผล: isolatedModules แปลว่า "ตัวแปลงต้องอ่านไฟล์นี้ไฟล์เดียวแล้วรู้เรื่อง" · พอบรรทัดแรกเขียน { type ButtonProps } ไว้แล้ว มันรู้ตั้งแต่บรรทัดแรกว่านี่คือ type — บรรทัด export ไม่ต้องบอกซ้ำ

ตัวเลือกที่ 1 คือคำตอบที่คนตอบกันมากที่สุด และเกือบถูก — TS1205 มีจริง แต่ต้องลบคำว่า type ออก ทั้งบรรทัด import และบรรทัด export ถึงจะเจอ · บทเรียนที่ติดตัวไปได้: อย่าท่องกฎ ให้รู้ว่ากฎมีไว้แก้ปัญหาอะไร แล้วคุณจะเดาถูกเองว่าเคสไหนเข้าข่ายไม่เข้าข่าย

7 · ภารกิจ — ทำเอง แล้วพิสูจน์ด้วยผลรัน ไม่ใช่ด้วยความรู้สึก

🛠 สร้าง Badge component แล้วพิสูจน์ทีละข้อ

ในโปรเจกต์ Next.js ที่คุณสร้างไว้จากบทที่แล้ว ทำตามลำดับนี้ · ห้ามข้ามข้อ 5 กับ 6

  1. สร้าง components/Bases/Badge.tsx ที่มี type ชื่อ BadgeProps ยืมมาจาก React.HTMLAttributes<HTMLSpanElement> แล้ว render เป็น <span> ที่เท restProps ลงไปและวาง children ไว้ตรงกลาง
    (ลอกโครงจากสไลด์หน้า 22 ได้ตรง ๆ เปลี่ยนแค่ button เป็น span)
  2. เพิ่ม props ของตัวเองหนึ่งช่อง: tone?: "info" | "danger" โดยใช้ & บวกกับ type เดิม · ตั้งค่าตั้งต้นเป็น "info" ตอน destructure
  3. สร้าง components/Bases/index.ts ที่ re-export ทั้ง Badge และ BadgeProps · อย่าลืมคำว่า type
  4. ใน app/page.tsx import จาก "@/components/Bases" แล้ววาง 3 ตัว: ตัวหนึ่งไม่ส่ง tone · ตัวหนึ่งส่ง tone="danger" · ตัวหนึ่งส่ง title="ทดสอบ"
  5. รัน npx tsc --noEmit ให้ผ่าน (exit 0) ก่อน แล้วรัน npx next dev · เปิดหน้าเว็บ กด View Page Source แล้วอ่าน HTML จริงของ span ทั้งสามตัว — ตอบให้ได้ว่า tone โผล่บน DOM หรือเปล่า และ title ไปถึงไหม
  6. ตั้งใจทำให้พังหนึ่งรอบ: เปลี่ยนชื่อ component จาก Badge เป็น badge ทั้งไฟล์ (รวมที่เรียกใช้) แล้วรัน npx tsc --noEmit ใหม่
    คุณควรได้ TS2339 · ก็อปข้อความ error ทั้งบรรทัดเก็บไว้ แล้วลองเปลี่ยนชื่อเป็น span ตัวเล็กดูอีกรอบ — คราวนี้จะไม่มี error แต่หน้าเว็บได้ผลผิด · ให้ curl ดู HTML แล้วอธิบายให้ตัวเองฟังว่าทำไม

ทำครบหกข้อแล้ว ตอบคำถามนี้ให้ได้ก่อนกดเฉลย: ทำไมข้อ 6 กรณี badge ถึงพัง แต่กรณี span ถึงไม่พัง ทั้งที่ทั้งสองอันตัวแรกเป็นตัวเล็กเหมือนกัน?

เพราะ React ตัดสินจากตัวอักษรตัวแรกอย่างเดียว ไม่ได้ดูว่าคุณประกาศฟังก์ชันชื่อนั้นไว้หรือเปล่า:

  • ตัวแรกเป็นตัวเล็ก → React ถือว่าเป็นแท็ก HTML เสมอ แล้วไปหาชื่อนั้นใน JSX.IntrinsicElements
  • badge ไม่มีในรายชื่อแท็ก HTML → TypeScript ฟ้อง TS2339
  • span มีในรายชื่อ → ผ่านฉลุย แล้วสร้างแท็ก <span> เปล่า ๆ ให้ · ฟังก์ชันที่คุณเขียนไว้ไม่ถูกเรียกเลย

บทเรียนที่เอาไปใช้ได้จริง: “build ผ่าน” ไม่เท่ากับ “ถูก” · ในงานจริง เวลาแก้ UI แล้วหน้าเว็บไม่เปลี่ยนตามที่คิด ให้เช็คตัวอักษรตัวแรกของชื่อ component เป็นอย่างแรก ๆ ก่อนจะไปนั่งไล่หา cache หรือ build system

8 · สรุปบทนี้เป็นสี่บรรทัด

เรื่องจำแค่นี้พอ
children คือของที่อยู่ระหว่างแท็กเปิดกับแท็กปิด · React ยัดเข้า props ให้เอง · ติดมากับ ButtonHTMLAttributes อยู่แล้ว ไม่ต้องประกาศ · ลืมวางแล้วไม่มีใครฟ้อง ได้ปุ่มเปล่า
{...restProps} ทำให้ปุ่มรับ onClick / disabled / type ได้ครบ โดยไม่ต้องเขียนเพิ่มสักบรรทัด · แต่ของที่ไม่ได้ดึงออกก่อนจะไหลไปโผล่บน DOM และ React 19 ไม่เตือน
index.ts เป็นแค่ไฟล์ธรรมดาที่ตั้งชื่อตรงกับข้อตกลง "เข้าโฟลเดอร์ให้เปิด index" · ลบทิ้งเมื่อไหร่ได้ TS2307 ทันที · re-export type ต้องมีคำว่า type อย่างน้อยหนึ่งที่ (บรรทัด import หรือบรรทัด export) · ไม่มีเลยสักที่ = TS1205
ชื่อ component ตัวแรกต้องเป็นตัวใหญ่ · ตัวเล็กที่ตรงกับแท็ก HTML = ผิดเงียบ ๆ ไม่มี error · ตัวเล็กที่ไม่ตรงแท็กไหน = TS2339

✅ ถ้าตอบสี่ข้อนี้ได้ แปลว่าบทนี้ผ่านแล้ว
1. <Button>ตกลง</Button> — คำว่า "ตกลง" ไปอยู่ในช่องชื่ออะไรของ props?
2. ทำไม Button.tsx เขียน React.ButtonHTMLAttributes ได้ทั้งที่ไม่มี import React?
3. ถ้าลบ components/Bases/index.ts ทิ้ง โค้ดที่ import จาก "@/components/Bases" จะได้ error เลขอะไร?
4. เขียน <section> ตัวเล็กทั้งที่ตั้งใจจะเรียก component ตัวเอง — TypeScript จะฟ้องไหม แล้วหน้าเว็บจะได้อะไร?

อ้างอิง