โหมดมืด
บทที่ 9 — Advanced Patterns (HOC, Render Props, Portals, Error Boundary, Suspense, Compound)
🔴 ระดับ: สูง — อ่านทีหลังได้ บทนี้เป็น "เทคนิคเชิงรูปแบบ" (pattern) ที่เจอในโค้ดมืออาชีพ — ค่อยกลับมาอ่านเมื่อเจอของจริงได้
🗺️ แผนการอ่าน (Reading roadmap) — อ่านบทนี้ยังไงให้ไม่ท่วม:
- อ่านครั้งแรก: Part 1 (HOC), Part 2 (Render Props), Part 4 (Error Boundary), Part 7 (Server Components preview) — concept ไม่ซับซ้อน อ่านเอาภาพรวม
- เจอของจริงค่อยมาเปิด: Part 3 (Portal — ตอนทำ modal/toast), Part 5 (Suspense — ตอนใช้ TanStack Query หรือ Next.js), Part 6 (Compound — ตอนเขียน Tabs/Accordion)
- ข้ามได้เลย: §20.5 Slot Pattern (
asChild) — pattern ขั้นสูงสำหรับ Radix/shadcn ไม่จำเป็นสำหรับมือใหม่
📖 คำศัพท์รวมของบท:
- pattern = แบบแผนการเขียนโค้ดที่ใช้ซ้ำได้ แก้ปัญหาประเภทเดิม ๆ
- HOC (Higher-Order Component) = function ที่รับ component แล้วคืน component ใหม่ที่ห่อ logic เพิ่ม
- render prop = ส่ง function เป็น prop ให้ child เรียก → child เลือกได้ว่า render อะไร
- Portal = render ที่ DOM อื่น (เช่น
<body>) เพื่อหลุดออกจาก parent ที่overflow: hidden- Error Boundary = component class ที่จับ exception ใน child (แม้ปี 2026 R19 จะใช้ function component เป็นหลัก แต่ Error Boundary เป็น ข้อยกเว้นเดียว ที่ยังต้องเป็น class — หรือใช้ library wrapper เช่น
react-error-boundaryแทน) จะเห็น syntax แบบ class (extends Component,this.state) ใน §11 — ถ้ายังไม่คุ้น class syntax ให้อ่านผ่านๆ ก่อน แล้วเก็บไว้ copy pattern- Suspense = component พิเศษที่ "รอ async" + แสดง fallback ระหว่างรอ
- Compound Component = component หลายชิ้นทำงานด้วยกันผ่าน Context ภายใน (เช่น
Tabs.Trigger,Tabs.Content)- Slot / asChild = pattern ของ Radix ที่ "ส่งต่อ" props ให้ child element แทนสร้าง wrapper เพิ่ม
- throw promise = เทคนิคที่ component "โยน" promise ที่ยังไม่เสร็จออกไปให้ Suspense จับ
- focus trap = บังคับ Tab key วน loop ใน element หนึ่ง (ใช้ใน modal)
บทนี้รวม "pattern" ที่เห็นในโค้ดมืออาชีพ + library — บางอันเก่า บางอันใหม่ แต่ทุกอันมีค่าให้รู้
หลังจบบท คุณจะ:
- เข้าใจ Higher-Order Component (HOC) (component ที่รับ component แล้วคืน component ใหม่ที่เสริมความสามารถให้) — pattern ก่อนยุค hooks
- ใช้ Render Props
- ใช้ Portal สำหรับ modal, tooltip, toast
- ใช้ Error Boundary จับ exception ใน render
- ใช้ Suspense สำหรับ async UI
- เขียน Compound Component (เช่น
Tabs.Trigger,Tabs.Content) - เข้าใจ Server Components (preview — ละเอียดใน Next.js)
Part 1: Higher-Order Component (HOC) — legacy pattern
📌 อ่านเพื่อเข้าใจของเก่า — ในปี 2026 hook แทน HOC ไปเกือบหมดแล้ว ส่วนนี้สอนเพื่อให้อ่าน codebase เก่า + library ที่ยังใช้ pattern นี้ (เช่น Redux
connect, React Router v5withRouter) ออก ไม่ต้องเขียน HOC ใหม่เองในปี 2026
1. HOC คืออะไร
HOC = function ที่รับ component แล้ว return component ใหม่ที่เสริมความสามารถแล้ว (enhanced — เช่น เพิ่ม auth check, logging, theme)
tsx
const EnhancedComponent = withLogger(MyComponent);ใน hooks era — HOC ลดความนิยม มาก แต่ยังเห็นในโค้ดเก่า + บาง library
2. ตัวอย่าง withAuth
HOC (Higher-Order Component) คือฟังก์ชันที่รับ component แล้วคืน component ใหม่ที่ห่อ logic เพิ่ม — withAuth เป็นตัวอย่างคลาสสิก: ห่อ component ใด ๆ ให้เช็ค auth ก่อน render:
📖
<P extends object>คืออะไร? —Pคือ generic type parameter (พารามิเตอร์ชนิดข้อมูลทั่วไป) ที่บอกว่า "P คือ type ของ props ที่ส่งเข้ามา — ไม่ว่าจะเป็น type อะไรก็ตาม" เหมือน placeholder สำหรับ type ถ้าชนิดข้อมูล TypeScript ยังไม่คุ้น แค่จำ pattern ไว้ก่อนได้
tsx
import { Navigate } from 'react-router-dom'; // Navigate มาจาก React Router
// useAuth คือ custom hook ที่ต้องสร้างหรือมาจาก auth library (เช่น NextAuth, Clerk)
// ไม่ใช่ hook ของ React เอง
function withAuth<P extends object>(
Component: React.ComponentType<P>
): React.ComponentType<P> {
return function AuthWrapper(props: P) {
const { user, isLoading } = useAuth();
if (isLoading) return <Spinner />;
if (!user) return <Navigate to="/login" />;
return <Component {...props} />;
};
}
// ใช้
const ProtectedDashboard = withAuth(Dashboard);
<Route path="/dashboard" element={<ProtectedDashboard />} />ปัจจุบันนิยม wrapper component หรือ hook แทน HOC แล้ว แต่ควรเข้าใจ pattern นี้ไว้เพราะยังเจอใน library เก่า — ปี 2026 ทำเป็น
<RequireAuth><Dashboard /></RequireAuth>(wrap component) ดีกว่า
3. ทำไม HOC ถูก deprecated (ส่วนใหญ่)
❌ Wrapper Hell (นรกของการห่อซ้อน — component ซ้อน HOC หลายชั้นจนสับสน):
tsx
withAuth(withTheme(withRouter(withTranslation(MyComponent))))❌ Props มาจากไหน? — MyComponent ได้ props ที่ไม่รู้ที่มา
❌ TypeScript ยุ่ง — generic type ซ้อน
✅ แทนด้วย hook:
tsx
function Dashboard() {
const { user } = useAuth(); // ⭐ ชัดเจน
const { theme } = useTheme();
const { t } = useTranslation();
// ...
}ใช้ HOC เมื่อ: third-party (เช่น
connect()ของ Redux เก่า,withRouterของ React Router v5)
Part 2: Render Props — legacy pattern
📌 อ่านเพื่อเข้าใจของเก่า — เช่นเดียวกับ HOC, hook แทน render props ไปเกือบหมด ยังเจอใน Formik, React Hook Form (Controller), framer-motion (AnimatePresence) — อ่านออกก็พอ ไม่ต้องสร้างใหม่
4. Render Props คืออะไร
Render Props เป็น pattern แชร์ logic อีกแบบก่อนยุค hook — component รับ "ฟังก์ชัน" เป็น prop (หรือ children) แล้วเรียกฟังก์ชันนั้นเพื่อ render โดยส่ง state ภายในให้
ทำให้ผู้ใช้ควบคุมการแสดงผลได้ แต่ logic ถูก encapsulate (ห่อหุ้ม — เก็บอยู่ภายในไม่หลุดออกมา) ไว้
📖
ReactNodeคือ type ของ React ที่ครอบคลุมทุกอย่างที่ render ได้: JSX/HTML element, string, number, null หรือ array ของสิ่งเหล่านั้น
Component ที่รับ function เป็น prop เพื่อ render content
tsx
import { useState, useEffect, type ReactNode } from 'react';
function MouseTracker({ render }: { render: (state: { x: number; y: number }) => ReactNode }) {
const [pos, setPos] = useState({ x: 0, y: 0 });
useEffect(() => {
const handler = (e: MouseEvent) => setPos({ x: e.clientX, y: e.clientY });
window.addEventListener('mousemove', handler);
return () => window.removeEventListener('mousemove', handler);
}, []);
return <>{render(pos)}</>;
}
// ใช้
<MouseTracker render={({ x, y }) => <p>Mouse: {x}, {y}</p>} />หรือใช้ children:
tsx
<MouseTracker>
{({ x, y }) => <p>Mouse: {x}, {y}</p>}
</MouseTracker>5. ใช้เมื่อไหร่?
ส่วนใหญ่ — ใช้ custom hook แทน:
tsx
function useMousePosition() {
const [pos, setPos] = useState({ x: 0, y: 0 });
useEffect(() => { /* เหมือน MouseTracker เดิม */ }, []);
return pos;
}
function Component() {
const { x, y } = useMousePosition();
return <p>Mouse: {x}, {y}</p>;
}แต่ Render Props ยังดีในบางกรณี:
- Component ที่ต้องการ "ควบคุมการ render" (เลือกได้ว่าจะแสดงผลเป็นอะไร — เช่น Form library, Animation library)
- เห็นใน Formik, React Hook Form (Controller), framer-motion (AnimatePresence)
Part 3: Portal
6. ปัญหา — modal ที่ติด overflow
tsx
<div className="overflow-hidden">
<Modal>I'm cut off!</Modal>
</div><Modal> render ใน DOM ตรงนี้ → ถูก overflow: hidden ตัด
ต้องการให้ render ที่ <body> เพื่อหนีจาก parent → Portal
7. ใช้ Portal
⚠️ ก่อนอ่านโค้ด: ตัวอย่างด้านล่างเป็น เรียนรู้เท่านั้น — ไม่มี focus trap จริง (Tab ยังหลุดออกจาก modal ได้) และ click-outside ผ่าน backdrop onClick มี edge case ใน a11y (accessibility — การเข้าถึงได้สำหรับทุกคน) ปี 2026 production ให้ใช้ Radix Dialog (
@radix-ui/react-dialog) หรือ shadcn/ui Dialog ที่ทำให้ครบถ้วนแล้ว — อย่า copy โค้ดนี้ไปใช้จริง📖
useRef<HTMLDivElement>—<HTMLDivElement>คือการบอก TypeScript ว่า ref นี้จะชี้ไปที่<div>element ถ้ายังไม่คุ้น TypeScript generics ตอนนี้แค่จำ pattern ไว้ก่อนได้📖
?.และas HTMLElement—?.คือ optional chaining (เรียก method/property ต่อเมื่อค่าฝั่งซ้ายไม่ใช่null/undefinedเท่านั้น เช่นdialogRef.current?.focus()= เรียก.focus()เฉพาะเมื่อdialogRef.currentมีค่าจริง ไม่งั้นข้ามไปเฉยๆ ไม่ throw error);as HTMLElementคือ type assertion (บอก TypeScript ว่า "เชื่อฉันเถอะ ค่านี้เป็น type อะไร" โดยไม่ต้องให้ TypeScript เดาเอง)
tsx
import { createPortal } from 'react-dom';
import { useEffect, useRef, type ReactNode } from 'react';
interface ModalProps {
children: ReactNode;
onClose: () => void;
}
function Modal({ children, onClose }: ModalProps) {
const dialogRef = useRef<HTMLDivElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
useEffect(() => {
// 1. capture element ที่ focus อยู่ก่อนเปิด modal
previousFocusRef.current = document.activeElement as HTMLElement;
// 2. focus เข้า modal
dialogRef.current?.focus();
// 3. lock body scroll
const original = document.body.style.overflow;
document.body.style.overflow = 'hidden';
// 4. ESC ปิด modal
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handleKey);
return () => {
document.body.style.overflow = original;
window.removeEventListener('keydown', handleKey);
// 5. คืน focus ให้ element เดิม
previousFocusRef.current?.focus();
};
}, [onClose]);
return createPortal(
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center"
onClick={onClose} // คลิก backdrop → ปิด
>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
tabIndex={-1} // ทำให้ focus ได้ด้วย JS
className="bg-white p-6 rounded shadow-lg outline-none"
onClick={e => e.stopPropagation()}
>
{children}
<button onClick={onClose}>Close</button>
</div>
</div>,
document.body
);
}💡 React event ยังทำงานปกติ — portal "render ที่ไหน" ต่างจาก "event bubble ที่ไหน" Event bubble (การที่ event ลอยขึ้นจาก child ไป parent) ใน React จะลอยตาม React tree ไม่ใช่ DOM tree — handler ที่ตั้งไว้ที่ parent จึงยังทำงาน แม้ portal จะ render ที่
<body>
⚠️ Modal production ที่ดี ต้องมี: focus trap (กักไม่ให้ Tab หลุดออกจาก modal), inert attribute (HTML attribute ที่ทำให้ element ทั้งกลุ่มกดไม่ได้ + screen reader ไม่อ่าน — ทำเนื้อหาหลัง modal ให้กดไม่ได้), enter/exit animation (animation ตอนเข้า/ออก) — ใช้ Radix Dialog หรือ shadcn/ui Dialog ที่ทำให้ครบแล้ว
8. ตัวอย่างจริง — Toast
ตัวอย่าง Portal ที่ใช้บ่อยจริงคือ Toast — render notification ไปที่ toast-root (อยู่นอก React root ของ app) เพื่อให้ลอยอยู่บนสุดเสมอ ไม่โดน overflow: hidden หรือ z-index ของ parent ตัด สังเกตว่า index.html มี div แยกไว้รับ portal:
tsx
// ToastContainer.tsx
// Toast เป็น type ที่ต้องสร้างเองหรือ import จาก toast library
interface Toast { id: string; message: string; }
function ToastContainer({ toasts }: { toasts: Toast[] }) {
// หา element ตอน render ไม่ใช่ตอน module โหลด — ปลอดภัยกว่าถ้าลำดับ script โหลดเปลี่ยน
// (ตัวอย่างนี้สมมติเป็น client-side app เช่น Vite — ถ้าเป็น SSR ต้องเช็ค document ก่อนใช้)
const toastRoot = document.getElementById('toast-root')!;
return createPortal(
<div className="fixed top-4 right-4 z-50 space-y-2">
{toasts.map(t => (
<div key={t.id} className="bg-white p-4 rounded shadow border-l-4 border-blue-500">
{t.message}
</div>
))}
</div>,
toastRoot
);
}html
<!-- index.html -->
<body>
<div id="root"></div>
<div id="toast-root"></div> <!-- ⭐ outside React root -->
</body>9. กรณีที่ใช้ Portal (Use Cases)
Portal เหมาะกับ UI ที่ต้อง "หลุดออกมาจาก layout ปกติ" — สิ่งที่ต้องลอยทับเนื้อหาและไม่ควรโดน overflow/z-index ของ parent ตัด เช่น modal, toast, tooltip, dropdown, lightbox จำกลุ่มนี้ไว้ว่าเมื่อไหร่ควรนึกถึง Portal:
- Modal / Dialog
- Toast / Notification
- Tooltip
- Dropdown menu (ที่ไม่ต้องการให้โดน overflow ตัด)
- Lightbox / Image preview
Part 4: Error Boundary
10. ปัญหา — exception ใน render
ปัญหาคือถ้า component throw error ตอน render โดยไม่มีอะไรดักไว้ React จะ unmount ทั้ง tree — ผู้ใช้เห็นจอขาวทั้งหน้า แทนที่จะเสียแค่ส่วนที่พัง Error Boundary แก้ปัญหานี้ด้วยการ catch error ใน child แล้วแสดง fallback แทนเฉพาะส่วนนั้น:
tsx
function Component() {
throw new Error('Oops!');
}ผลคือ app ทั้ง app ตาย — React unmount everything
→ Error Boundary = component ที่ catch error ใน child + show fallback
11. เขียน Error Boundary
Error Boundary เป็นข้อยกเว้นเดียวที่ยังต้องเขียนเป็น class component (React ยังไม่มี hook version) — ใช้ getDerivedStateFromError เก็บ error เพื่อ render fallback และ componentDidCatch สำหรับ log ไป service (Sentry):
📖 class component คืออะไร? — วิธีเขียน React แบบเก่าก่อนยุค hook ใช้
classแทนfunction,this.stateแทนuseState, และthis.propsแทน parameter ของ function — ปกติเราไม่เขียนแบบนี้ใหม่แล้วในปี 2026 แต่ ErrorBoundary เป็นข้อยกเว้นเดียว — หรือจะใช้ libraryreact-error-boundaryแทนเพื่อหลีกเลี่ยง class ก็ได้ (ดู §13)
tsx
import { Component, ReactNode } from 'react';
interface Props {
children: ReactNode;
fallback: (error: Error, reset: () => void) => ReactNode;
}
interface State {
error: Error | null;
}
export class ErrorBoundary extends Component<Props, State> {
state: State = { error: null };
static getDerivedStateFromError(error: Error): State {
return { error };
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
console.error('ErrorBoundary caught:', error, info);
// ส่งไป Sentry หรือ logging service อื่น ๆ
}
reset = () => this.setState({ error: null });
render() {
if (this.state.error) {
return this.props.fallback(this.state.error, this.reset);
}
return this.props.children;
}
}ใช้:
tsx
<ErrorBoundary fallback={(error, reset) => (
<div>
<h2>Something went wrong</h2>
<p>{error.message}</p>
<button onClick={reset}>Try again</button>
</div>
)}>
<RiskyComponent />
</ErrorBoundary>12. ⚠️ Error Boundary ไม่ catch อะไรบ้าง
ข้อจำกัดที่ต้องรู้: Error Boundary catch ได้เฉพาะ error ที่เกิดตอน render เท่านั้น — ไม่ catch error ใน event handler, async code (setTimeout/Promise), SSR หรือ error ในตัว boundary เอง สำหรับกรณีเหล่านี้ต้องใช้ try/catch + setState เอง:
- Event handler —
onClick={() => { throw ... }}❌ - Async code (โค้ดแบบไม่รอ) —
setTimeout,Promise.then❌ - Server-side rendering ❌
- Error ใน boundary ตัวเอง ❌
สำหรับ event handler / async — ใช้ try/catch + setState
13. ใช้ react-error-boundary (library)
ไม่ต้องเขียน class เองทุกครั้ง — library react-error-boundary ห่อให้พร้อมใช้ มี FallbackComponent (รับ error + resetErrorBoundary) และ onReset ที่นิยมใช้คู่กับ refetch ของ TanStack Query (library สำหรับดึงข้อมูล — ดูบทที่ 4) เพื่อให้ "Try again" ลองดึงข้อมูลใหม่:
bash
npm install react-error-boundarytsx
import { ErrorBoundary, type FallbackProps } from 'react-error-boundary';
function ErrorFallback({ error, resetErrorBoundary }: FallbackProps) {
return (
<div role="alert">
{/* ในงานจริง ให้เปลี่ยนข้อความเป็นภาษาที่ผู้ใช้อ่านได้ เช่น "เกิดข้อผิดพลาด" และ "ลองใหม่" */}
<p>Something went wrong:</p>
<pre>{error.message}</pre>
<button onClick={resetErrorBoundary}>Try again</button>
</div>
);
}
<ErrorBoundary FallbackComponent={ErrorFallback} onReset={() => refetch()}>
<App />
</ErrorBoundary>14. Strategy — Place Boundary หลายชั้น
กลยุทธ์สำคัญคือวาง Error Boundary หลายชั้นตามระดับความสำคัญ — boundary นอกสุดกัน app crash ทั้งหมด, boundary ย่อยรอบแต่ละส่วน (sidebar, page) ทำให้ส่วนหนึ่งพังไม่ลามทั้งหน้า ยิ่งวางละเอียด ผู้ใช้ยิ่งเสียพื้นที่น้อยเมื่อเกิด error:
tsx
<ErrorBoundary fallback={<AppCrashed />}>
<Header />
<ErrorBoundary fallback={<SidebarFailed />}>
<Sidebar />
</ErrorBoundary>
<ErrorBoundary fallback={<PageError />}>
<Routes>...</Routes>
</ErrorBoundary>
</ErrorBoundary>→ Sidebar พัง ≠ app ทั้ง app ตาย
Part 5: Suspense
15. Suspense คืออะไร
Suspense = "ให้ component บอกว่ามัน loading โดยไม่ต้อง render"
tsx
import { Suspense } from 'react';
<Suspense fallback={<Spinner />}>
<UserProfile userId={1} />
</Suspense>ถ้า UserProfile "suspend" (loading) → Suspense render <Spinner /> แทน
ตอนนี้ใช้ได้กับ:
React.lazy()— code splitting (บทที่ 5, ส่วน Routing) ถ้ายังไม่ได้อ่านบทที่ 5 ข้ามไปก่อนได้- TanStack Query
useSuspenseQuery(บทที่ 4 — library สำหรับ data fetching) ถ้ายังไม่ได้อ่านบทที่ 4 ข้ามตัวอย่างนี้ไปก่อนได้ - Next.js App Router (บทที่ 11)
- ไม่ใช้กับ regular
useEffect+ fetch
16. ตัวอย่างกับ TanStack Query
Suspense เปล่งประกายเมื่อใช้คู่ useSuspenseQuery — component ไม่ต้องเช็ค isLoading/error เองเลย (data ไม่มีทาง undefined) ปล่อยให้ <Suspense> จัดการ loading และ <ErrorBoundary> จัดการ error ที่ระดับบน โค้ด component จึงเหลือแต่ happy path (เส้นทางสำเร็จ — เคสที่ทุกอย่างไปได้ดี ไม่มี error) ที่สะอาด:
tsx
import { useSuspenseQuery } from '@tanstack/react-query';
function UserProfile({ userId }: { userId: number }) {
const { data } = useSuspenseQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
// ไม่ต้อง check isLoading — Suspense handle ให้
return <div>{data.name}</div>;
}
// ใช้ — ErrorBoundary ต้องครอบ Suspense ด้านนอก เพื่อจับ error ที่เกิดหลัง suspend ได้
<ErrorBoundary fallback={<ErrorMessage />}>
<Suspense fallback={<Spinner />}>
<UserProfile userId={1} />
</Suspense>
</ErrorBoundary>⚠️ ลำดับสำคัญ:
<ErrorBoundary>ต้องอยู่ นอก<Suspense>เสมอ — ถ้าวางกลับกัน (<Suspense>ครอบ<ErrorBoundary>) ตัว Suspense จะจับก่อน ErrorBoundary ไม่มีโอกาสรับ error
โค้ดสะอาดมาก — ไม่มี if (isLoading), if (error) ใน component
17. Nested Suspense
ซ้อน Suspense หลายชั้นเพื่อให้ UI ทยอยแสดง — ส่วนที่พร้อมก่อน (Header) ปรากฏทันที ส่วนที่ยังโหลด (UserProfile, Posts) แสดง skeleton ของตัวเองแยกกัน ผู้ใช้เห็นเนื้อหาเร็วขึ้นแทนที่จะรอทั้งหน้าโหลดพร้อมกัน:
tsx
<Suspense fallback={<PageSkeleton />}>
<Header />
<Suspense fallback={<UserSkeleton />}>
<UserProfile />
</Suspense>
<Suspense fallback={<PostsSkeleton />}>
<UserPosts />
</Suspense>
</Suspense>→ Header แสดงเร็ว, UserProfile + UserPosts แสดงแยกกัน
Part 6: Compound Component Pattern
18. ปัญหา — component ที่มีหลาย "sub-part"
tsx
<Tabs
items={[
{ label: 'Home', content: <HomeView /> },
{ label: 'About', content: <AboutView /> },
]}
/>ปัญหา:
- ปรับ label ลำบาก (icon? badge?)
- ใส่ content ที่ซับซ้อนยาก
- ลำดับ rendering ไม่ explicit
19. Compound Component
Compound component แก้ปัญหานี้ด้วยการให้ component ย่อย (Tabs.List, Tabs.Trigger, Tabs.Content) ทำงานร่วมกันผ่าน context ภายใน — ผู้ใช้ประกอบเองได้อิสระเหมือนเขียน HTML แต่ state แชร์กันอัตโนมัติ นี่คือ pattern ที่ Radix/Headless UI ใช้
📖 Context API คือกลไกของ React สำหรับแชร์ข้อมูลระหว่าง component โดยไม่ต้องส่ง props ทีละชั้น (สอนในบทที่ 3) — ใน Compound Component จะสร้าง context ซ่อนไว้ภายใน ผู้ใช้ไม่เห็น แต่ sub-component อ่านได้เองอัตโนมัติ
tsx
<Tabs defaultValue="home">
<Tabs.List>
<Tabs.Trigger value="home">Home</Tabs.Trigger>
<Tabs.Trigger value="about">About</Tabs.Trigger>
<Tabs.Trigger value="contact" disabled>Contact</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="home"><HomeView /></Tabs.Content>
<Tabs.Content value="about"><AboutView /></Tabs.Content>
<Tabs.Content value="contact"><ContactView /></Tabs.Content>
</Tabs>อ่านง่ายเหมือน HTML element + customize ได้สูง
20. Implementation
เบื้องหลัง compound component คือ Context — Tabs สร้าง context เก็บ activeTab แล้ว sub-component (Trigger/Content) อ่าน/เขียน context นั้น การผูก sub-component เป็น property ของ parent (Tabs.List) ทำให้ import ง่ายและสื่อความสัมพันธ์ชัด:
tsx
import { createContext, useContext, useState, ReactNode } from 'react';
interface TabsContextValue {
activeTab: string;
setActiveTab: (value: string) => void;
}
// | null หมายความว่า context มีค่าเป็น null เมื่อยังไม่มี <Tabs> ห่ออยู่
// useTabsContext ด้านล่างจะ throw error ทันทีเพื่อให้รู้ว่าใช้ sub-component ผิดที่
// (throw ทันทีแบบนี้เพื่อให้ error message ชัดเจนตอน dev เขียนโค้ดผิด แทนที่จะเจอ bug เงียบๆ ทีหลัง)
const TabsContext = createContext<TabsContextValue | null>(null);
function useTabsContext() {
const ctx = useContext(TabsContext);
if (!ctx) throw new Error('Tabs.* must be inside <Tabs>');
return ctx;
}
// Root (ชื่อ TabsRoot ก่อน แล้วรวมกับ sub-component ทีหลังด้วย Object.assign)
function TabsRoot({ defaultValue, children }: { defaultValue: string; children: ReactNode }) {
const [activeTab, setActiveTab] = useState(defaultValue);
return (
// React 19 + @types/react ^19: ใช้ <Context> ตรงๆ ไม่ต้อง .Provider แล้ว
// ถ้าใช้ React 18 หรือ @types/react เวอร์ชันเก่า ให้เปลี่ยนเป็น <TabsContext.Provider value={...}>
<TabsContext value={{ activeTab, setActiveTab }}>
<div className="tabs">{children}</div>
</TabsContext>
);
}
// Sub-components
function TabList({ children }: { children: ReactNode }) {
return <div role="tablist" className="flex gap-2 border-b">{children}</div>;
}
function TabTrigger({ value, disabled, children }: { value: string; disabled?: boolean; children: ReactNode }) {
const { activeTab, setActiveTab } = useTabsContext();
const isActive = activeTab === value;
return (
<button
role="tab"
aria-selected={isActive}
disabled={disabled}
onClick={() => setActiveTab(value)}
className={`px-4 py-2 ${isActive ? 'border-b-2 border-blue-500' : ''}`}
>
{children}
</button>
);
}
function TabContent({ value, children }: { value: string; children: ReactNode }) {
const { activeTab } = useTabsContext();
if (activeTab !== value) return null;
return <div role="tabpanel" className="py-4">{children}</div>;
}
// ผูก sub-component เข้ากับ Tabs (namespace) ให้ใช้แบบ Tabs.List, Tabs.Trigger ได้
// ใช้ Object.assign เพื่อให้ TypeScript ไม่ error เรื่อง property ที่ยังไม่ได้ declare
const Tabs = Object.assign(TabsRoot, {
List: TabList,
Trigger: TabTrigger,
Content: TabContent,
});
export { Tabs };⚠️ a11y ของตัวอย่างนี้ไม่ครบ — ขาด
aria-controls(เชื่อม Trigger กับ Content),aria-labelledbyและ keyboard navigation ด้วยลูกศร ตามมาตรฐาน ARIA Tabs pattern ในงานจริงแนะนำให้ใช้ Radix UI Tabs หรือ Headless UI แทน
ใช้:
tsx
<Tabs defaultValue="home">
<Tabs.List>
<Tabs.Trigger value="home">Home</Tabs.Trigger>
<Tabs.Trigger value="settings">⚙ Settings</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="home"><HomeContent /></Tabs.Content>
<Tabs.Content value="settings"><Settings /></Tabs.Content>
</Tabs>Radix UI, shadcn/ui — ใช้ pattern นี้
20.5 Slot Pattern (asChild) — pattern ที่ Radix/shadcn ใช้
ปัญหา: เมื่ออยากให้ <Button> ของเรา render เป็น <a> แทน (เพราะเป็นลิงก์) — แต่ยังเอา style/behavior ของ Button — เราจะ:
- ❌ duplicate props (ทำซ้ำ prop ทุกตัว — เช่น
as="a"+ รับ ref + spread{...props}= กระจาย props ทั้งหมดลงไป) → boilerplate (โค้ดซ้ำซากที่ต้องเขียนทุกครั้ง) เยอะ - ✅ ใช้ Slot pattern ที่ "ส่งต่อ" prop ทั้งหมดไปยัง child element แทน
tsx
<Button asChild>
<a href="/about">About</a> {/* render เป็น <a> แต่มี style + behavior ของ Button */}
</Button>Implementation (เรียบง่าย)
tsx
import { Slot } from '@radix-ui/react-slot';
// cn คือ utility function สำหรับ merge class names — มาจาก clsx + tailwind-merge
// shadcn/ui มีให้ที่ @/lib/utils หรือติดตั้งเอง: npm install clsx tailwind-merge
import { cn } from '@/lib/utils';
// React.ComponentProps<'button'> = type รวมของ props ทุกตัวที่ <button> ปกติรับได้
// เช่น onClick, disabled, type — ช่วยให้รับ props เหล่านั้นผ่านได้โดยไม่ต้องพิมพ์ทีละตัว
interface ButtonProps extends React.ComponentProps<'button'> {
asChild?: boolean;
variant?: 'primary' | 'ghost';
}
function Button({ asChild, variant = 'primary', className, ...props }: ButtonProps) {
const Comp = asChild ? Slot : 'button';
return (
<Comp
className={cn(
'inline-flex h-10 items-center px-4 rounded-md',
variant === 'primary' && 'bg-blue-500 text-white',
variant === 'ghost' && 'hover:bg-gray-100',
className
)}
{...props}
/>
);
}ใช้:
tsx
// render เป็น <button>
<Button>Save</Button>
// render เป็น <a> แต่มี style + className ของ Button
<Button asChild>
<a href="/docs">Docs</a>
</Button>
// render เป็น <Link> ของ react-router หรือ next/link
<Button asChild>
<Link to="/dashboard">Dashboard</Link>
</Button>Slot ทำงานยังไง
<Slot> ของ Radix:
- Clone child element ที่เราใส่
- Merge props (className, onClick, ref) ของ Slot เข้ากับ props เดิมของ child
- Forward ref ไปที่ child
ผลลัพธ์: child element มี style/behavior ของ parent แต่ทำหน้าที่ semantic (ความหมายทาง HTML) ของ tag ตัวเอง (a, Link, button) → ดีต่อ a11y และ navigation
💡 shadcn/ui ใช้ pattern นี้ในทุก component — Button, Card, Tooltip, Dropdown ฯลฯ มี
asChildหมด ทำให้ compose ได้สวยมาก
Part 7: Server Components (React 19+, Next.js App Router)
21. ⚡ ภาพรวม
React 19 + Next.js App Router มี Server Components — component ที่ render บน server, ไม่ส่ง JS มา client
tsx
// Server Component (default ใน Next.js)
async function UserList() {
// db คือ Prisma ORM client สำหรับ query database — อธิบายละเอียดบทที่ 11
// ตอนนี้แค่เข้าใจ concept ว่า Server Component เรียก database ได้โดยตรง
const users = await db.user.findMany(); // ⭐ async + direct DB call!
return (
<ul>
{users.map(u => <li key={u.id}>{u.name}</li>)}
</ul>
);
}ข้อดี:
- ไม่ส่ง JS ไป client (No JS shipped) — มีแค่ HTML
- DB call ใน component ได้ — ไม่ต้องทำ API endpoint
- Faster initial load
ข้อจำกัด:
- ใช้
useState,useEffectไม่ได้ (ไม่มี state, ไม่มี browser) - ต้องประกาศ
'use client'ถ้าต้องการ interactive
รายละเอียดในบทที่ 11 (Next.js)
Part 8: ตัวอย่างเต็ม — App ที่ใช้ทุก pattern
tsx
function App() {
return (
<ErrorBoundary fallback={<AppCrashed />}>
<Suspense fallback={<PageSkeleton />}>
<Header />
<main>
<ErrorBoundary fallback={<SectionError />}>
<Suspense fallback={<ContentSkeleton />}>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/profile" element={<ProfilePage />} />
</Routes>
</Suspense>
</ErrorBoundary>
</main>
<Footer />
</Suspense>
{/* ตัดส่วน state management ออกเพื่อความกระชับ — จริงๆ ต้องมี isOpen + onClose */}
<Modal isOpen={isModalOpen} onClose={() => setModalOpen(false)}>
<ConfirmDialog />
</Modal>
<Toaster />
</ErrorBoundary>
);
}22. ⚠️ Pitfalls
| Pitfall | แก้ |
|---|---|
| HOC ซ้อนหลายชั้น → debug ยาก | ใช้ hook + composition |
| Render prop ที่ใช้ได้กับ hook | ใช้ hook |
| Portal ที่ event handler ไม่ทำงาน | event bubble ตาม React tree — handler ปกติ |
| Modal ใน portal โดน scroll lock | จัดการ body.overflow เมื่อ open |
| ErrorBoundary ไม่ catch async | ใช้ try/catch + state ใน component |
| Suspense + non-suspense data | ผสม ใช้ useSuspenseQuery ทั้งหมด |
| Compound — Sub-component นอก Root | โยน error ชัดเจน (เช่น throw new Error('Tabs.* must be inside <Tabs>')) เมื่อ child ถูกใช้นอก parent |
23. Checkpoint
📝 หมายเหตุ: checkpoint บทนี้ ตั้งใจไม่มีเฉลย ให้ลองทำเองก่อน ถ้าติดให้ย้อนไปดูตัวอย่างโค้ดในบทแล้วดัดแปลง
🛠️ Checkpoint 9.1 — Modal ด้วย Portal
สร้าง <Modal isOpen onClose>{children}</Modal> ที่:
- render ที่ document.body
- ESC ปิด
- click outside ปิด
- focus trap (Tab ไม่ออกจาก modal)
🛠️ Checkpoint 9.2 — Error Boundary
ทำ <ErrorBoundary fallback> แล้วใช้กับหน้า /users → ทำให้ component throw → ดู fallback แทนหน้าทั้งหน้าตาย
🛠️ Checkpoint 9.3 — Compound Component
ทำ <Accordion> ที่:
tsx
<Accordion>
<Accordion.Item value="1">
<Accordion.Header>Section 1</Accordion.Header>
<Accordion.Content>...</Accordion.Content>
</Accordion.Item>
</Accordion>- multi/single mode
- a11y (ARIA)
🛠️ Checkpoint 9.4 — Suspense + useSuspenseQuery
แทน useQuery ด้วย useSuspenseQuery + Suspense + ErrorBoundary → component code สั้นลง
24. สรุปบท
✅ HOC = function ที่ enhance component — ลดความนิยม, ใช้ hook แทน
✅ Render Props = function เป็น children — ใช้ hook แทนได้ส่วนใหญ่
✅ Portal = render ที่ DOM อื่น (body) — สำหรับ modal, toast, tooltip
✅ Error Boundary = catch error ใน render — class component (React ยังไม่มี hook-based Error Boundary API — class component ยังคงจำเป็นสำหรับ production ในปี 2026)
✅ ErrorBoundary ไม่ catch event handler / async — ใช้ try/catch
✅ Suspense = async UI — ใช้กับ React.lazy, useSuspenseQuery, Server Components
✅ Compound Component = Tabs.Trigger, Tabs.Content — Radix/shadcn ใช้ pattern นี้
✅ Server Components = render บน server, no JS shipped — ดูในบทที่ 11 ✅ Slot Pattern (Radix <Slot>, asChild prop) = pass props ลง child โดยไม่ wrap div เพิ่ม
25. React 19 — Patterns ใหม่
<Context> shorthand (React 19+)
ก่อน — ต้องเขียน <MyContext.Provider value={...}>:
tsx
<ThemeContext.Provider value={theme}>
<App />
</ThemeContext.Provider>React 19 — เขียนตรง ๆ ได้:
tsx
<ThemeContext value={theme}> {/* ⭐ ไม่ต้อง .Provider */}
<App />
</ThemeContext>Ref เป็น prop (React 19+) — default pattern ปี 2026
ปี 2026 — รับ ref เป็น prop ตรง ๆ ได้แล้ว forwardRef กลายเป็น legacy (ของเก่า/ไม่แนะนำใช้แล้ว — ยังเจอใน codebase เก่า/library เก่า แต่ของใหม่ไม่ต้องใช้):
📖
forwardRefคืออะไร? — wrapper ที่ทำให้ function component รับrefprop ได้ (ก่อน React 19refไม่ใช่ prop ปกติ ต้องใช้forwardRefพิเศษ) — ในปี 2026 ไม่ต้องใช้แล้ว แต่ต้องอ่านออกเมื่อเจอในโค้ดเก่า
tsx
import { type Ref } from 'react';
// กำหนด type ของ props ที่ <input> รับได้ทั้งหมด
type InputProps = React.ComponentPropsWithoutRef<'input'>;
// ❌ ก่อน (pre-19 legacy (ของเก่า/ไม่แนะนำใช้แล้ว) — ยังเห็นในของเก่า)
import { forwardRef } from 'react';
const InputOld = forwardRef<HTMLInputElement, InputProps>((props, ref) => (
<input ref={ref} {...props} />
));
// ✅ React 19+ — default pattern
function Input({ ref, ...props }: InputProps & { ref?: Ref<HTMLInputElement> }) {
return <input ref={ref} {...props} />;
}ต้องใช้ React 19 +
@types/react^19
Document Metadata (React 19+)
ใส่ <title>, <meta> ที่ไหนใน tree ก็ได้ — React จะ hoist (ยกขึ้น) ขึ้น <head> ให้
💡 hoist = React จัดการย้าย
<title>และ<meta>ขึ้นไปอยู่ใน<head>ให้อัตโนมัติ ไม่ว่าจะเขียนไว้ที่ไหนใน component tree
tsx
function BlogPost({ post }) {
return (
<article>
<title>{post.title}</title>
<meta name="description" content={post.excerpt} />
<h1>{post.title}</h1>
...
</article>
);
}→ ไม่ต้องใช้ react-helmet แล้ว