

สร้าง Component ครั้งเดียว ใช้ได้ทุกที่ – ลดความซ้ำซ้อน เพิ่มประสิทธิภาพงานพัฒนา
เรียนรู้แนวทางออกแบบ Reusable Component ที่ทำให้โค้ดสะอาด ลดการซ้ำซ้อน และปรับตัวได้กับทุกโปรเจกต์
นักพัฒนาหลายคนอาจคุ้นเคยกับการเขียนโค้ด UI อยู่แล้ว เช่น ปุ่มล็อกอิน ปุ่มบันทึก หรือกล่องแสดงข้อความ เป็นต้น UI เหล่านี้มักจะมีดีไซน์ที่คล้ายกัน แต่ต่างกันเล็กน้อย เช่น สีหรือขนาด ซึ่งเราก็สามารถแก้ไขค่าเหล่านั้นได้ตรง ๆ อย่างไม่ยากเย็น แต่เมื่อโปรเจกต์เราใหญ่ขึ้น ความซ้ำซ้อนเหล่านี้จะกลายเป็นภาระหนักในการบำรุงรักษา และเป็นแหล่งที่มาของ bug ได้ง่าย
เพื่อแก้ปัญหาเหล่านี้ แนวคิด Reusable Component จึงมีความสำคัญมากในงานพัฒนา ไม่ว่าจะเป็นการเขียนด้วย React, Vue, Angular หรือแม้แต่ Laravel ที่มี Blade Components ก็ตาม การสร้างเพียงครั้งเดียวแต่สามารถนำไปใช้ซ้ำในหลายที่ ไม่เพียงช่วยลดภาระ แต่ยังทำให้ทีมทำงานอย่างมีระบบและคุณภาพมากยิ่งขึ้น
ในบทความนี้ เราจะมาดูกันว่า Reusable Component คืออะไร หลักการออกแบบที่ดีควรเป็นแบบไหน รวมถึงวิธีการสร้างจริง พร้อมทั้งตัวอย่างโค้ด และแนวทางปฏิบัติที่ช่วยให้คุณสร้างโค้ดที่สะอาดและมีประสิทธิภาพต่อไป
💡 Reusable Component คืออะไร#
Reusable Component คือ Component สำหรับแสดงผลต่อผู้ใช้งาน (UI Component) ที่สามารถนำไปใช้ซ้ำได้ในหลาย ๆ ที่ โดยไม่ต้องเขียนโค้ดใหม่ทุกครั้ง เช่น ปุ่ม (Button), ฟอร์มกรอกข้อมูล (Form Field), การ์ด (Card), หรือ Modal เป็นต้น
🛠️ หลักการออกแบบ#
ในการออกแบบ Reusable Component ที่ดี คือการออกแบบ Component โดยคำนึงถึงหลักการต่อไปนี้
-
Single Responsibility Principle (SRP) - แต่ละ Component ควรทำหน้าที่เฉพาะ เช่น ปุ่ม (Button) ควรทำหน้าที่สำหรับแสดงผลและรับ event กด เท่านั้น ไม่ควรผูกกับ logic อื่นที่ไม่เกี่ยวข้อง
-
Configurable - Component ควรออกแบบให้สามารถปรับแต่งได้ เช่น ผ่าน Props, Slot, Parameter หรือ Attribute โดยไม่ต้องเขียนใหม่
-
Composable - Component ควรจะสามารถนำมาประกอบกับ Component อื่นได้โดยง่าย เช่น ฟอร์มหนึ่งชุดอาจประกอบด้วย Component Input, Label และ Button
-
Consistency - Component ควรสร้างความสม่ำเสมอให้กับ UI ทั้งระบบ เพื่อประสบการณ์ของผู้ใช้ที่ดี
⚠️ ปัญหาที่เกิดขึ้นเมื่อไม่ใช้ Reusable Component#
ในทางกลับกัน หากระบบที่พัฒนาอยู่ไม่ได้คำนึงถึงหลักการออกแบบ Reusable Component ข้างต้น ก็อาจเกิดปัญหาขึ้นได้ดังนี้
- โค้ดซ้ำซ้อน (Duplication Code) - มีโค้ดชุดเดียวกันซ้ำในหลายไฟล์ ทำให้โปรเจกต์ใหญ่ขึ้นโดยไม่จำเป็น
- บำรุงรักษายาก (Maintenance Overhead) - หากต้องเปลี่ยนสีหรือรูปแบบปุ่มทั้งโปรเจกต์ ต้องแก้ทุกไฟล์ที่เคยใช้ ทำให้มีโอกาสตกหล่นหรือผิดพลาด
- เกิดความไม่สอดคล้องกันของ UI (Inconsistency UI) - UI บางจุดอาจเกิดความผิดพลาดให้มีขนาดหรือสีที่ต่างกัน ทำให้เกิดความสับสนต่อผู้ใช้งาน
- ข้อเสียต่อการพัฒนาระบบ - การไม่ออกแบบ Reusable Component อาจทำให้การพัฒนาใช้เวลามากกว่าที่ควรจะเป็น หรืออาจเกิดความผิดพลาดได้ง่ายกว่า
🚀 ตัวอย่างการออกแบบ Reusable Component#
สมมุติว่าเราต้องการสร้าง UI ของปุ่มขึ้นมา 1 ปุ่มเพื่อใช้งาน โดยใช้ CSS framework เป็น tailwindcss เราอาจคำนึงถึงโอกาสที่ปุ่มนี้จะถูกเรียกใช้ในจุดอื่นและวางโครงสร้างไว้ดังนี้
// Button.tsx
type ButtonProps = {
variant?: "primary" | "secondary";
size?: "sm" | "md" | "lg";
children: React.ReactNode;
};
export const Button = ({
variant = "primary",
size = "md",
children,
}: ButtonProps) => {
const base = "rounded px-4 font-semibold";
const variants = {
primary: "bg-blue-500 text-white",
secondary: "bg-gray-200 text-black",
};
const sizes = {
sm: "text-sm py-1",
md: "text-base py-2",
lg: "text-lg py-3",
};
return (
<button className={`${base} ${variants[variant]} ${sizes[size]}`}>
{children}
</button>
);
};tsxเมื่อต้องการใช้งาน เราก็เพียงแค่เรียกผ่าน Props ดังนี้
<Button variant="primary" size="lg">Save</Button>
<Button variant="secondary" size="sm">Cancel</Button>tsxจากตัวอย่างจะเห็นว่าปุ่มที่เราสร้างขึ้น สามารถกำหนดสีหรือขนาดผ่าน Props ได้เลยโดยไม่ต้องแก้ไขโค้ดอีก หรือหากต้องการปรับปรุงเพิ่มเติมในอนาคตก็สามารถทำได้โดยง่าย
🎯 เพิ่มประสิทธิภาพการใช้งานด้วย CVA (Class Variance Authority)#
CVA (Class Variance Authority) คือ utility function สำหรับจัดการ className ของ Tailwind ที่มี variants (เช่น intent, size, state) ได้อย่างเป็นระบบและปลอดภัยเชิงชนิดข้อมูล (type-safe) กว่าการต่อ string เอง เพราะได้ autocomplete และตรวจ variant ที่ผิดได้ตั้งแต่ตอนเขียน หากต้องการใช้งาน CVA ก็สามารถทำได้โดยง่ายดังนี้
// Button.tsx
import React from "react";
import { cva, type VariantProps } from "class-variance-authority";
const button = cva("rounded px-4 py-2 font-semibold", {
variants: {
intent: {
primary: "bg-blue-500 text-white",
secondary: "bg-gray-200 text-black",
},
size: {
sm: "text-sm",
md: "text-base",
lg: "text-lg",
},
},
defaultVariants: {
intent: "primary",
size: "md",
},
});
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof button> {}
export const Button: React.FC<ButtonProps> = ({
className,
intent,
size,
...props
}) => <button className={button({ intent, size, className })} {...props} />;tsxเมื่อต้องการใช้งาน เราก็เพียงแค่เรียกผ่าน Props ดังนี้
<Button intent="primary">Save</Button>
<Button intent="secondary" size="sm">Cancel</Button>tsxcompoundVariants – กฎพิเศษเมื่อ variant หลายตัวมาเจอกัน#
บางครั้งเราอยากให้ปุ่มมี style พิเศษเฉพาะ “เมื่อ variant หลายตัวมาประจวบเหมาะกัน” เช่น เฉพาะปุ่ม intent="primary" ที่ size="lg" เท่านั้นที่อยากให้มีเงาหนาขึ้นและตัวอักษรหนาเป็นพิเศษ แต่กรณีอื่นไม่ต้อง
ถ้าใช้วิธีต่อ string เองคงต้องเขียน if ซ้อนกันให้วุ่นวาย แต่ CVA เตรียม compoundVariants ไว้ให้แล้ว โดยเขียนเป็น array ของ object ที่ระบุเงื่อนไข variant ที่ต้องตรงพร้อมกัน แล้วใส่ class ที่จะเพิ่มในคีย์ class (ผู้ใช้ React จะเขียนเป็น className ก็ได้)
// Button.tsx (เพิ่ม compoundVariants)
const button = cva("rounded px-4 py-2 font-semibold", {
variants: {
intent: {
primary: "bg-blue-500 text-white",
secondary: "bg-gray-200 text-black",
},
size: {
sm: "text-sm",
md: "text-base",
lg: "text-lg",
},
},
compoundVariants: [
// จะมีผลก็ต่อเมื่อ intent=primary และ size=lg พร้อมกันเท่านั้น
{
intent: "primary",
size: "lg",
class: "shadow-lg font-bold tracking-wide",
},
],
defaultVariants: {
intent: "primary",
size: "md",
},
});tsxนอกจากนี้ค่าในเงื่อนไขยังรับเป็น array ได้ด้วย เช่น intent: ["primary", "secondary"] หมายถึงให้ class นี้มีผลเมื่อ intent เป็น primary หรือ secondary ตัวใดตัวหนึ่ง ช่วยลดการเขียนกฎซ้ำได้มาก
ผูก tailwind-merge เข้ากับ CVA จริง#
ในงานจริง CVA มักถูกใช้คู่กับ tailwind-merge (และบางครั้ง clsx) เพื่อรวม className ที่ส่งเข้ามาจากภายนอกกับ class ของ variants แล้วจัดการกรณี class ของ Tailwind ชนกันให้ตัวที่มาทีหลังชนะอย่างถูกต้อง
ปัญหาคือถ้าเราต่อ string ตรง ๆ แล้วมี class ชนกัน เช่น variant กำหนด px-4 มาแล้ว แต่ผู้เรียกส่ง px-8 เข้ามาเพิ่ม สุดท้าย DOM จะได้ทั้ง px-4 px-8 ซึ่ง Tailwind ไม่รับประกันว่าตัวไหนชนะ
twMerge แก้ปัญหานี้โดยรู้จัก Tailwind และจะเก็บเฉพาะตัวที่มาทีหลัง (px-8) ให้ ส่วนใหญ่นิยมห่อ twMerge กับ clsx ไว้เป็น helper ชื่อ cn แล้วเรียกใช้ที่เดียว
// lib/cn.ts
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs));tsx// Button.tsx (ส่งผลลัพธ์ของ cva ผ่าน cn)
import { cn } from "./lib/cn";
export const Button: React.FC<ButtonProps> = ({
className,
intent,
size,
...props
}) => (
<button className={cn(button({ intent, size }), className)} {...props} />
);tsxจุดที่ต่างจากตัวอย่างก่อนหน้าคือ เราแยก className จากภายนอกออกมาแล้วส่งผ่าน cn(...) แทนที่จะยัดเข้า button({ ..., className }) ตรง ๆ เพื่อให้ twMerge มีโอกาสจัดการ class ที่ชนกันให้เรียบร้อยก่อนนำไปใช้จริง
forwardRef – ทำไม Reusable Component ควร forward ref#
Reusable Component ที่ห่อ element จริง (เช่น <button>, <input>) ควรเปิดทางให้ผู้เรียกเข้าถึง DOM node ข้างในได้ผ่าน ref ด้วย เพราะหลายงานต้องใช้ ref เช่น การสั่ง focus เอง (focus management), การเลื่อนหน้าจอไปยัง element หรือการต่อกับ form library อย่าง React Hook Form ที่อาศัย ref ในการอ่านค่า/สั่งโฟกัส
หากเราไม่ forward ref ออกไป ผู้เรียกจะเข้าไม่ถึง <button> ตัวจริงเลย วิธีมาตรฐานคือห่อ component ด้วย React.forwardRef
// Button.tsx (รองรับ ref)
import React from "react";
import { type VariantProps } from "class-variance-authority";
import { cn } from "./lib/cn";
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof button> {}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, intent, size, ...props }, ref) => (
<button
ref={ref}
className={cn(button({ intent, size }), className)}
{...props}
/>
)
);
Button.displayName = "Button";tsxหมายเหตุ: ตั้งแต่ React 19 เป็นต้นไป เราสามารถรับ
refเป็น prop ปกติได้โดยไม่ต้องห่อforwardRefแล้ว (เขียน({ ref, ...props }) => ...ตรง ๆ ได้) แต่ถ้ายังอยู่บนเวอร์ชันก่อนหน้า การใช้forwardRefยังเป็นวิธีที่ถูกต้องและใช้งานได้อยู่
♿ อย่าลืม Accessibility (a11y)#
Reusable Component ที่ดีไม่ได้วัดกันแค่หน้าตาหรือความยืดหยุ่นของ Props เท่านั้น แต่ต้องใช้งานได้กับทุกคน รวมถึงผู้ที่ใช้ keyboard หรือ screen reader ด้วย ข้อดีของการรวม UI ไว้ที่เดียวคือ เราจัดการ accessibility ให้ถูกต้อง “ครั้งเดียว” แล้วทั้งระบบได้ประโยชน์ทันที โดยมีจุดสำคัญที่ควรใส่ใจดังนี้
- ส่งต่อ
aria-*และ attribute มาตรฐาน - การ spread{...props}ใน component ทำให้ผู้เรียกส่งaria-label,aria-pressed,type,disabledหรือ attribute อื่น ๆ เข้ามาได้โดยไม่ต้องแก้ไขตัว component นี่คือเหตุผลที่เรา extendReact.ButtonHTMLAttributes<HTMLButtonElement>ไว้ตั้งแต่แรก - จัดการสถานะ
disabledให้ครบ - นอกจากส่ง propdisabledลงไปที่<button>แล้ว ควรใส่ style ที่สื่อสารด้วยสายตา เช่นdisabled:opacity-50 disabled:cursor-not-allowedเพื่อให้ผู้ใช้รู้ว่าปุ่มกดไม่ได้จริง - มี focus ring ที่มองเห็นได้ - ผู้ที่ใช้ keyboard tab ไปมาต้องเห็นว่าตอนนี้โฟกัสอยู่ที่ปุ่มไหน แนะนำให้ใช้
focus-visible:ringแทนfocus:ringเพราะจะแสดง ring เฉพาะตอน tab ด้วยคีย์บอร์ด ไม่เด้งตอนคลิกเมาส์ จึงดูสะอาดกว่า - รองรับ keyboard โดยใช้ element ที่ถูกต้อง - ถ้าเป็นปุ่ม ให้ใช้
<button>จริง (ไม่ใช่<div onClick>) เพราะจะได้พฤติกรรม keyboard เช่น กด Enter/Space และ focus order มาให้ฟรี ไม่ต้องเขียนonKeyDownหรือtabIndexเองให้พลาด
ตัวอย่างการใส่ class เหล่านี้ลงใน base ของ CVA เพื่อให้ทุกปุ่มได้ทันที
// Button.tsx (base class ที่คำนึงถึง a11y)
const button = cva(
[
"rounded px-4 py-2 font-semibold",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2",
"disabled:opacity-50 disabled:cursor-not-allowed",
],
{
/* variants ... */
}
);tsx🐘 แล้วฝั่ง Laravel ล่ะ? – Blade Component#
แนวคิด Reusable Component ไม่ได้จำกัดอยู่แค่ฝั่ง JavaScript นักพัฒนาสาย Laravel ก็มี Blade Component ที่ทำงานบนหลักการเดียวกัน คือสร้างครั้งเดียวแล้วเรียกใช้ซ้ำได้ทั้งโปรเจกต์ โดยรับค่าผ่าน @props แทน Props ของ React
{{-- resources/views/components/button.blade.php --}}
@props([
'intent' => 'primary',
'size' => 'md',
])
@php
$intents = [
'primary' => 'bg-blue-500 text-white',
'secondary' => 'bg-gray-200 text-black',
];
$sizes = [
'sm' => 'text-sm py-1',
'md' => 'text-base py-2',
'lg' => 'text-lg py-3',
];
@endphp
<button
{{ $attributes->class([
'rounded px-4 font-semibold',
'focus-visible:ring-2 focus-visible:ring-blue-500',
'disabled:opacity-50 disabled:cursor-not-allowed',
$intents[$intent],
$sizes[$size],
]) }}
>
{{ $slot }}
</button>bladeเมื่อต้องการเรียกใช้ก็เพียง
<x-button intent="primary" size="lg">Save</x-button>
<x-button intent="secondary" size="sm">Cancel</x-button>bladeจะเห็นว่าแนวคิดเหมือนกับ React ทุกประการ เพียงเปลี่ยนวิธีเขียน โดย $attributes ของ Blade ทำหน้าที่คล้าย {...props} คือส่งต่อ attribute ที่ผู้เรียกใส่เข้ามา (เช่น type, aria-label) ลงไปยัง <button> ให้อัตโนมัติ และ $slot ก็เทียบได้กับ children
📏 ควร reuse เมื่อไหร่ – กฎ Rule of Three#
คำถามที่เจอบ่อยคือ “แล้วเมื่อไหร่ถึงควรแยกออกมาเป็น Reusable Component?” แนวทางที่นิยมใช้กันคือ Rule of Three ซึ่งบอกว่า ให้รอจนเห็นโค้ดรูปแบบเดียวกันถูกใช้ครบ 3 ครั้งก่อน แล้วค่อย abstract ออกมา เหตุผลคือ ครั้งแรกเรายังไม่รู้ว่ารูปแบบนี้จะถูกใช้ซ้ำจริงไหม ครั้งที่สองเริ่มเห็นเค้าลาง แต่ยังเร็วไปที่จะสรุปว่าอะไรคือส่วนที่ต่างกันจริง ๆ พอถึงครั้งที่สามรูปแบบการใช้งานมักชัดเจนพอจะออกแบบ interface ที่ครอบคลุมโดยไม่ต้องเดา การรีบ abstract ตั้งแต่เห็นซ้ำครั้งแรก (premature abstraction) มักจบลงด้วย component ที่มี Props เต็มไปหมดเพื่อรองรับทุกความต่าง จนกลายเป็นของที่อ่านยากและแก้ยากกว่าเดิม ทั้งนี้ Rule of Three เป็นแนวทาง ไม่ใช่กฎตายตัว ถ้ารูปแบบชัดเจนตั้งแต่แรก เช่น ปุ่มของ design system ก็ออกแบบเป็น Reusable ได้เลยโดยไม่ต้องรอครบสามครั้ง
☕ สรุปส่งท้าย#
จากตัวอย่างจะเห็นว่า Reusable Component ไม่เพียงแค่ประหยัดเวลาในการเขียนโค้ด แต่คือแนวคิดที่ทำให้ทีมทำงานอย่างเป็นระบบ มีความสอดคล้อง และบำรุงรักษาง่ายขึ้น โดยยึดหลักการสำคัญในการออกแบบที่ดี ซึ่งผลลัพธ์ปลายทางมักจะทำให้โค้ดของโปรเจกต์สะอาดขึ้น และทีมทำงานได้อย่างมีประสิทธิภาพมากขึ้น ทั้งนี้ก็ควรระวังเรื่อง over-abstraction ด้วย อย่ารีบทำให้ทุกอย่างเป็น Reusable ก่อนเวลาอันควร เพราะการ reuse ทั้งที่ยังไม่เห็นรูปแบบการใช้งานที่ชัดเจน อาจได้ component ที่ซับซ้อนเกินจำเป็น แนะนำให้เริ่ม reuse เมื่อเห็นการใช้ซ้ำจริงแล้ว
🥰