Skip to content

บทที่ 9 — Advanced Patterns (HOC, Render Props, Portals, Error Boundary, Suspense, Compound)

← บทที่ 8 | สารบัญ | บทที่ 10 →

🔴 ระดับ: สูง — อ่านทีหลังได้ บทนี้เป็น "เทคนิคเชิงรูปแบบ" (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 v5 withRouter) ออก ไม่ต้องเขียน 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 เป็นข้อยกเว้นเดียว — หรือจะใช้ library react-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-boundary
tsx
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:

  1. Clone child element ที่เราใส่
  2. Merge props (className, onClick, ref) ของ Slot เข้ากับ props เดิมของ child
  3. 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 รับ ref prop ได้ (ก่อน React 19 ref ไม่ใช่ 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 แล้ว


← บทที่ 8 | บทที่ 10 → Animation + Performance