Devfavor

Back

สร้าง Component ครั้งเดียว ใช้ได้ทุกที่ – ลดความซ้ำซ้อน เพิ่มประสิทธิภาพงานพัฒนาBlur image

นักพัฒนาหลายคนอาจคุ้นเคยกับการเขียนโค้ด 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>
tsx

compoundVariants – กฎพิเศษเมื่อ 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 นี่คือเหตุผลที่เรา extend React.ButtonHTMLAttributes<HTMLButtonElement> ไว้ตั้งแต่แรก
  • จัดการสถานะ disabled ให้ครบ - นอกจากส่ง prop disabled ลงไปที่ <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 เมื่อเห็นการใช้ซ้ำจริงแล้ว

🥰

Code, coffee, and calm — the holy trinity of a good day. ☕

สร้าง Component ครั้งเดียว ใช้ได้ทุกที่ – ลดความซ้ำซ้อน เพิ่มประสิทธิภาพงานพัฒนา
Author Coffee Stack
Published at 21 สิงหาคม 2025