วิธีลด props ด้วย compound component และการจัดกลุ่ม props

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

เผยแพร่
เวลาอ่าน
5 นาที
แท็ก
#Next.js#React

หมวด 3 · Componentบทที่ 8 จาก 14

สารบัญบทเรียน บทที่ 8 จาก 14
ในบทนี้ 11 หัวข้อ
  1. props เยอะแค่ไหนถึงควรเปลี่ยน
  2. props มีสองแบบ และแก้คนละวิธี
  3. ตารางข้อมูลก่อนและหลังเป็น compound component
  4. สร้าง compound component ด้วย context
  5. compound component มีข้อจำกัดสามข้อ
  6. ค่าตั้งธรรมดาให้จัดกลุ่มเป็น object
  7. ใช้ variant แทน boolean ที่คู่กัน
  8. ย้ายผู้เรียกทีละชุด
  9. เมื่อไหร่ไม่ควรเปลี่ยน
  10. จากงานจริง
  11. อ้างอิง

component ที่ใช้หลายหน้า เช่น ตารางข้อมูล layout ของหน้าตั้งค่า หรือ dialog กลาง มักโตด้วยวิธีเดียวกัน หน้าไหนอยากได้อะไรเพิ่มก็เติม prop ให้อีกตัว เติมไปสองสามปีก็กลายเป็น props สี่สิบตัวที่ไม่มีใครจำได้ว่าตัวไหนทำอะไร บทนี้ว่าด้วยการพา component แบบนั้นกลับมาเหลือ props ไม่กี่ตัว

props เยอะแค่ไหนถึงควรเปลี่ยน

เกณฑ์ที่เราใช้ในบทเรียนนี้คือ props 8 ตัวให้เริ่มระวัง 12 ตัวให้แยก แต่จำนวนอย่างเดียวยังไม่บอกทั้งหมด อาการที่ชัดกว่ามีสามอย่าง

  • boolean ที่เปิดปิดทั้งส่วน เช่น showToolbar, selectable, hideExport, compact ทุกตัวคือ if ก้อนใหญ่ข้างใน component และทุกคู่ที่ใช้ร่วมกันคือกรณีที่ต้อง test เพิ่ม
  • props ที่ขึ้นต้นเหมือนกันเป็นกลุ่ม เช่น navItems, navActive, navOnSelect, navCollapsible บอกว่ามีก้อนที่ควรเป็นของชิ้นเดียวกัน
  • props แบบ renderX เช่น renderToolbar, renderEmpty บอกว่าคนเรียกอยากวาดส่วนนั้นเอง แต่ต้องอ้อมผ่านฟังก์ชัน

props มีสองแบบ และแก้คนละวิธี

ก่อนจะลงมือ ให้แยก props ออกเป็นสองกอง

แบบ ตัวอย่าง วิธีแก้
คุมส่วนของหน้าจอ search, onSearchChange, pagination, emptyMessage, toolbarTitle compound component
ค่าตั้งธรรมดา title, subtitle, navItems, userAvatar, collapsible จัดกลุ่มเป็น object

กองแรกคือ props ที่ถ้าไม่ส่งมา ส่วนนั้นของหน้าจอก็ไม่ควรมีอยู่ คนเรียกจึงควรเป็นคนเลือกว่าจะใส่ส่วนไหน กองที่สองคือค่าที่ component ต้องใช้วาดตัวเอง ไม่มีส่วนไหนหายไปแค่เพราะไม่ได้ส่งค่า

ตารางข้อมูลก่อนและหลังเป็น compound component

// ก่อน: component เดียว props 40 กว่าตัว ทุกส่วนตั้งค่าจากข้างนอก
<DataTable
  data={rows} columns={columns}
  search={q} onSearchChange={setQ}
  selectable selectedIds={sel} onSelectionChange={setSel}
  sort={sort} onSortChange={setSort}
  pagination={{ page, pageSize, total, onPageChange: setPage }}
  emptyMessage="ยังไม่มีคำสั่งซื้อ" emptyIcon={Inbox} emptyAction={<NewOrderButton />}
  hideAutoExport toolbarTitle="คำสั่งซื้อ"
/>

// หลัง: props หลักเหลือไม่กี่ตัว ส่วนต่าง ๆ เป็นชิ้นที่คนเรียกใส่เอง
<DataTable data={rows} columns={columns}>
  <DataTable.Toolbar title="คำสั่งซื้อ" hideAutoExport />
  <DataTable.Search value={q} onChange={setQ} />
  {canDelete && <DataTable.Selection value={sel} onChange={setSel} />}
  <DataTable.Sort value={sort} onChange={setSort} />
  <DataTable.Pagination page={page} pageSize={pageSize} total={total} onPageChange={setPage} />
  <DataTable.Empty message="ยังไม่มีคำสั่งซื้อ" icon={Inbox} action={<NewOrderButton />} />
</DataTable>

ตัวหลังอ่านแล้วเห็นเลยว่าตารางนี้มีส่วนอะไรบ้าง หน้าที่ไม่ต้องค้นหาก็ไม่ใส่ DataTable.Search หน้าที่เลือกแถวได้เฉพาะคนมีสิทธิ์ลบก็ใส่เงื่อนไขตรง ๆ ไม่ต้องคำนวณ selectable={canDelete} แล้วไปหวังว่าข้างในจะซ่อนส่วนที่เกี่ยวข้องให้ครบ

อีกข้อดีที่ตามมาคือ props ของแต่ละชิ้นอยู่กับชิ้นนั้น DataTable.Pagination มี props ห้าตัวที่เกี่ยวกับการแบ่งหน้าเท่านั้น เวลาจะเพิ่มความสามารถใหม่ให้การแบ่งหน้า ก็แก้ชิ้นนั้นชิ้นเดียว ไม่ต้องเพิ่ม prop ตัวที่สี่สิบเอ็ดให้ทั้งตาราง

สร้าง compound component ด้วย context

ชิ้นย่อยต้องรู้ข้อมูลของตารางตัวแม่ เช่น จำนวนแถว หรือแถวที่เลือกอยู่ วิธีที่ตรงที่สุดคือให้ตัวแม่ส่งข้อมูลผ่าน context แล้วชิ้นย่อยอ่านเอา

'use client';

const TableContext = createContext<TableState<unknown> | null>(null);

function useTable() {
  const table = use(TableContext);
  if (!table) throw new Error('DataTable parts must be inside <DataTable>');
  return table;
}

function DataTableRoot<T>({ data, columns, children }: DataTableProps<T>) {
  const table = useTableState(data, columns);   // แถวที่แสดง การเรียง ฯลฯ
  return (
    <TableContext value={table}>
      <div className="data-table">{children}<TableBody /></div>
    </TableContext>
  );
}

function Pagination({ page, pageSize, total, onPageChange }: PaginationProps) {
  const { visibleCount } = useTable();
  return <Pager page={page} pages={Math.ceil(total / pageSize)} shown={visibleCount} onChange={onPageChange} />;
}

export const DataTable = Object.assign(DataTableRoot, { Toolbar, Search, Selection, Sort, Pagination, Empty });
export { Toolbar as DataTableToolbar, Search as DataTableSearch, Pagination as DataTablePagination };

React 19 ให้เขียน <TableContext value={…}> ได้เลยโดยไม่ต้องมี .Provider และอ่านด้วย use(TableContext) ได้ ส่วน useTable ที่ throw เมื่อไม่มีตัวแม่ช่วยให้ใช้ผิดแล้วรู้ทันทีตอนพัฒนา แทนที่จะได้ตารางที่ส่วนหนึ่งหายไปเงียบ ๆ

ถ้าตัวแม่ต้องรู้ว่าคนเรียกใส่ชิ้นไหนมาบ้าง เช่น เพื่อจัดวางแถบเครื่องมือไว้บนสุดเสมอ ไม่ว่าจะเขียนไว้ลำดับไหน วิธีที่ใช้กันคืออ่าน children ด้วย Children.toArray แล้วเทียบ type ของแต่ละชิ้น วิธีนี้ใช้ได้ แต่มีเงื่อนไขที่ต้องรู้ในหัวข้อถัดไป

compound component มีข้อจำกัดสามข้อ

ชิ้นย่อยต้องเป็นลูกตรงของตัวแม่ ถ้าตัวแม่อ่าน children ถ้าคนเรียกห่อ DataTable.Search ด้วย component ของตัวเอง ตัวแม่จะมองไม่เห็น type ของ DataTable.Search อีกต่อไป ให้ตัดสินใจไว้ตั้งแต่แรกว่าจะทำอะไรกับลูกที่ไม่รู้จัก เช่น เลข 0 ที่หลุดมาจาก {count && …} ข้อความ หรือ wrapper จะข้ามไป หรือ throw ให้รู้ตัว แล้วเขียน test ให้กรณีนั้น ถ้าออกแบบให้ชิ้นย่อยอ่าน context อย่างเดียวโดยตัวแม่ไม่ต้องอ่าน children ข้อจำกัดนี้ก็หายไป

server component ใช้จุดเข้าถึงชิ้นย่อยไม่ได้ ตารางที่มี state เป็น client component ถ้าหน้าที่เรียกใช้เป็น server component แล้วเขียน <DataTable.Search /> React จะแจ้ง error ว่าเข้าถึงสมาชิกของ client module จากฝั่ง server ไม่ได้ เพราะสิ่งที่ server component ได้มาตอน import คือตัวอ้างอิงไปยัง client component ไม่ใช่ตัว component จริง ทางแก้คือ export ชิ้นย่อยเป็นชื่อแยกด้วย (DataTableSearch) ตามบรรทัดสุดท้ายของตัวอย่างข้างบน หรือย้ายส่วนที่ประกอบตารางไปไว้ใน client component ตัวเล็ก ๆ เรื่องเส้นแบ่ง server กับ client มีละเอียดในบท ใช้ Server Component เป็นหลัก

คนเรียกต้องเขียนมากขึ้นเล็กน้อย ตารางที่ใช้ค่าเริ่มต้นทุกอย่าง จากเดิมบรรทัดเดียวอาจกลายเป็นห้าบรรทัด ถ้ามีหน้าจอสิบหน้าที่ใช้ชุดเดียวกันหมด ให้ทำ component รวมชุดนั้นไว้อีกตัว เช่น OrdersTable แทนที่จะเพิ่ม prop กลับเข้าไปในตารางกลาง

ค่าตั้งธรรมดาให้จัดกลุ่มเป็น object

props กองที่สองไม่ใช่ส่วนของหน้าจอ เปลี่ยนเป็นชิ้นย่อยก็ไม่ได้อะไร ให้จัดกลุ่มตามคำนำหน้าที่เห็นอยู่แล้วเป็น object ไม่กี่ก้อน

// ก่อน: props แบนยี่สิบกว่าตัว
<SettingsShell title={t} subtitle={s} navItems={items} navActive={active}
  navOnSelect={onSelect} navCollapsible navDefaultOpen={open}
  userName={user.name} userAvatar={user.image} userMenu={menu} />

// หลัง: สามกลุ่ม แต่ละกลุ่มเป็น object เดียว
<SettingsShell
  header={{ title: t, subtitle: s }}
  nav={{ items, active, onSelect, collapsible: true, defaultOpen: open }}
  user={{ name: user.name, avatar: user.image, menu }}
/>

function SettingsShell({ header, nav, user, children }: SettingsShellProps) {
  const { items, active, onSelect, collapsible = false, defaultOpen = 'hover' } = nav;
  // effect และ memo ต้องพึ่งค่าที่แกะออกมาแล้ว ห้ามพึ่ง `nav` ทั้งก้อน
}

กับดักเดียวของวิธีนี้คือ object ที่สร้างใหม่ทุกครั้งที่ parent render ถ้าใส่ nav ทั้งก้อนไว้ใน dependency array ของ effect หรือใช้เปรียบเทียบใน memo ทุก render จะนับเป็นการเปลี่ยนแปลง effect จะรันใหม่ทุกรอบ ให้แกะ object ที่บรรทัดแรกของ component แล้วพึ่งแต่ค่าที่แกะออกมา ค่าเริ่มต้นก็ย้ายไปอยู่ตรงที่แกะ เหมือนตอนที่เคยเขียนไว้ในรายการ props

ใช้ variant แทน boolean ที่คู่กัน

boolean หลายตัวที่ใช้ด้วยกันมักเป็น variant ที่ยังไม่ได้ตั้งชื่อ เช่น compact, dense และ borderless ที่ใช้คู่กันเสมอในหน้ารายการ แต่ไม่เคยมีหน้าไหนใช้ dense โดยไม่มี compact ให้รวมเป็น prop เดียวที่บอกชื่อแบบตรง ๆ

// ก่อน: 8 แบบที่เป็นไปได้ แต่ใช้จริงแค่ 2
<DataTable compact dense borderless … />

// หลัง: บอกชื่อแบบที่ใช้จริง
<DataTable variant="list" … />

แบบที่ใช้จริงมีกี่แบบก็มีค่าเท่านั้น คนอ่านโค้ดไม่ต้องเดาว่า dense อย่างเดียวหน้าตาเป็นยังไง และคนเขียน component ก็ไม่ต้องรองรับชุดที่ไม่มีใครใช้

ย้ายผู้เรียกทีละชุด

ตารางกลางที่มีคนเรียกยี่สิบกว่าหน้า เปลี่ยน API รวดเดียวไม่ได้ ทางที่ปลอดภัยคือ

  1. ถ่ายผลการ render ของทุกหน้าที่เรียกไว้ก่อน เขียน test ที่ render แต่ละหน้าแล้วเก็บ HTML ไว้เป็น snapshot commit ไว้ก่อนแก้อะไร
  2. เพิ่มชิ้นย่อยใหม่ โดย props เดิมยังใช้ได้ ตัวแม่รองรับทั้งสองแบบช่วงหนึ่ง
  3. ย้ายผู้เรียกทีละชุด แต่ละชุดรัน snapshot เทียบกับของเดิม HTML ต้องเหมือนเดิมทุกตัวอักษร
  4. ลบ props เดิม เมื่อไม่มีผู้เรียกเหลือแล้ว

snapshot ของ HTML บอกได้แค่ว่าหน้าตาเหมือนเดิม แต่ไม่ได้บอกว่าคลิกแล้วทำงานเหมือนเดิม ถ้ามีการโต้ตอบที่สำคัญ เช่น เลือกแถวแล้วกดลบ ให้เพิ่ม test ที่คลิกจริงด้วย

เมื่อไหร่ไม่ควรเปลี่ยน

  • component ที่มีคนเรียกที่เดียว ไม่ต้องทำ API ให้สวย props เยอะก็แค่ส่งต่อจาก parent ตัวเดียว แยกเป็นชิ้นย่อยตามบท แยก component ย่อยที่รับ props และส่ง callback ออก ก็พอ
  • props ที่จริง ๆ เป็นข้อมูลชิ้นเดียว เช่น pagination={{ page, pageSize, total }} เป็น object อยู่แล้ว ไม่ต้องแยกเป็นชิ้นย่อยถ้าไม่มีหน้าไหนอยากวาดส่วนนั้นเอง
  • ไลบรารี UI ที่มี compound component อยู่แล้ว เช่น Radix หรือ shadcn/ui ใช้รูปแบบของไลบรารีนั้นตรง ๆ อย่าห่อด้วย component ที่รับ props ยี่สิบตัวกลับเข้าไปอีกชั้น

จากงานจริง

ในงาน refactor ร้านค้าออนไลน์ Next.js 16 กับ CMS ที่เป็น Next.js admin ใน pnpm monorepo ตารางข้อมูลกลางของระบบหลังร้านเคยรับ props 45 ตัว หลังเปลี่ยนเป็น compound component เหลือ 11 ตัว โดยมีหน้าจอที่เรียกใช้ 28 หน้า ซึ่งย้ายทีละชุดตามขั้นตอนข้างบน และทุกหน้าที่ย้ายมี snapshot ของ HTML commit ไว้ก่อนเปลี่ยน ส่วน layout ของหน้าตั้งค่า รายการบทความ และ shell ของแอป ใช้วิธีจัดกลุ่ม props จาก 23, 27 และ 15 ตัว เหลือ 8, 8 และ 7 ตัว สิ่งที่ตรวจบ่อยตอนรีวิวคือ object ที่จัดกลุ่มแล้วหลุดเข้าไปอยู่ใน dependency array ทั้งก้อน

อ้างอิง

บทถัดไป

อ่านต่อ

ถูกอ้างถึงใน

ล่าสุด

ดูทั้งหมด →