บทก่อนหน้า แยก custom hook ทีละเรื่อง ย้าย logic ที่มี state ออกจาก component ใหญ่ไปไว้ใน hook ที่เหลืออยู่ใน component คือ JSX ยาว ๆ บทนี้ว่าด้วยการแบ่ง JSX ส่วนนั้นเป็นชิ้น และเส้นแบ่งที่ทำให้ชิ้นเหล่านั้นใช้งานง่าย คือข้อมูลเข้าทาง props เหตุการณ์ออกทาง callback
component ย่อยที่ดึงข้อมูลเองจะผูกกับทั้งแอป
อาการที่เห็นบ่อยคือ component ที่อยู่ลึกลงไปในต้นไม้เรียก useCartStore() หรือเรียก server action เอง ตอนเขียนก็สะดวกดี เพราะไม่ต้องส่ง props ผ่านหลายชั้น แต่ผลที่ตามมามีหลายอย่าง
- render แยกไม่ได้ จะเขียน test ให้แถวในตะกร้าหนึ่งแถว ต้องตั้ง store ทั้งก้อน mock action และบางทีต้องมี provider ครอบอีกสามชั้น
- ใช้ซ้ำไม่ได้ แถวสินค้าแบบเดียวกันในหน้าสรุปคำสั่งซื้อ ใช้ตัวเดิมไม่ได้ เพราะตัวเดิมผูกกับตะกร้า สุดท้ายก็ copy ไปแก้เป็นอีกไฟล์
- หาไม่เจอว่าใครเปลี่ยนข้อมูล เมื่อหลายชิ้นในหน้าเดียวต่างเรียก action เอง การไล่ว่าจำนวนสินค้าเปลี่ยนเพราะอะไรต้องเปิดทุกไฟล์
วิธีเช็กง่าย ๆ คือถามว่า "ถ้าส่ง props ครบ ชิ้นนี้ render ได้เลยไหม" ถ้าคำตอบคือต้องมี store, provider หรือ network ก่อน ชิ้นนั้นยังไม่ใช่ component ย่อยที่แยกได้จริง
เลือกจุดแบ่ง JSX จากส่วนของหน้าจอ
ก่อนจะแยก ลองดูหน้าจอจริงแล้วถามว่าคนใช้เรียกส่วนไหนว่าอะไร ส่วนที่เรียกชื่อได้มักเป็นจุดแบ่งที่ดี จุดแบ่งที่เจอบ่อยมีสามแบบ
- ส่วนของหน้าจอ เช่น หัวของหน้า แถบเครื่องมือ ฟอร์ม ส่วนท้ายที่มีปุ่มบันทึก แต่ละส่วนรับข้อมูลเฉพาะที่ตัวเองแสดง
- ของที่วนซ้ำ ทุก
.map()ที่ข้างในยาวเกินสองสามบรรทัดคือ component ย่อยที่รอตั้งชื่อ เช่น แถวในตะกร้า การ์ดสินค้า หรือคอลัมน์ของตาราง - ของที่เปลี่ยนพร้อมกัน ส่วนที่แสดงหรือซ่อนด้วยเงื่อนไขเดียวกัน เช่น กล่องข้อความเมื่อตะกร้าว่าง หรือแถบแจ้งเตือนเมื่อสินค้าหมด แยกเป็นชิ้นแล้วเงื่อนไขจะอ่านง่ายขึ้น
ตอนแยก ให้ไล่ดูว่าชิ้นนั้นใช้ตัวแปรอะไรจาก scope ของ parent บ้าง ตัวแปรที่อ่านอย่างเดียวกลายเป็น props ส่วน handler ที่ชิ้นนั้นเรียกกลายเป็น callback ถ้ารายการ props ยาวเกินแปดตัว แปลว่าชิ้นนั้นยังใหญ่ไป หรือแบ่งผิดที่ ลองแบ่งให้เล็กลงอีกระดับ
ตัวอย่างแถวในตะกร้าก่อนและหลังแยก
// ก่อน: แถวดึงข้อมูลจาก store และเรียก server เอง
function CartRow({ id }: { id: string }) {
const line = useCartStore((s) => s.lines[id]);
return (
<Row>
{line.name}
<QtyStepper value={line.qty} onChange={(qty) => updateLine(id, qty)} />
<button onClick={() => removeLine(id)}>ลบ</button>
</Row>
);
}
// หลัง: ข้อมูลเข้าทาง props เหตุการณ์ออกทาง callback
type CartRowProps = {
line: CartLine;
onQtyChange: (qty: number) => void;
onRemove: () => void;
};
function CartRow({ line, onQtyChange, onRemove }: CartRowProps) {
return (
<Row>
{line.name}
<QtyStepper value={line.qty} onChange={onQtyChange} />
<button onClick={onRemove}>ลบ</button>
</Row>
);
}
// ใน CartList ซึ่งเป็น component หลัก เป็นที่เดียวที่รู้จัก store
function CartList() {
const cart = useCart();
return cart.lines.map((line) => (
<CartRow
key={line.id}
line={line}
onQtyChange={(qty) => cart.setQty(line.id, qty)}
onRemove={() => cart.remove(line.id)}
/>
));
}
CartRow ตัวหลังไม่รู้ว่าตะกร้าเก็บที่ไหน ไม่รู้ว่าการเปลี่ยนจำนวนต้องเรียก API ตัวไหน รู้แค่ว่าแถวนี้แสดงอะไรและผู้ใช้ทำอะไรได้บ้าง ถ้าหน้าสรุปคำสั่งซื้ออยากแสดงแถวแบบเดียวกันแต่ห้ามแก้จำนวน ก็ส่ง QtyStepper แบบอ่านอย่างเดียวผ่าน prop อีกตัว หรือแยก OrderRow ที่ใช้ชิ้นย่อยเดียวกัน
สังเกตว่า callback ไม่ส่ง id กลับมา CartRow แจ้งแค่ว่า "จำนวนเปลี่ยนเป็น 3" ส่วนการผูกกับ line.id เกิดใน CartList ที่รู้อยู่แล้วว่ากำลังวาดแถวไหน ชิ้นย่อยจึงไม่ต้องรู้เรื่อง key ของข้อมูลเลย
ตั้งชื่อ callback ตามสิ่งที่ผู้ใช้ทำ
callback ของชิ้นย่อยคือรายการเหตุการณ์ที่ชิ้นนั้นแจ้งได้ ตั้งชื่อเป็น on + สิ่งที่เกิดขึ้น เช่น onQtyChange, onRemove, onSelect อย่าตั้งตามสิ่งที่ parent จะทำต่อ เช่น updateCartLine หรือ deleteFromServer เพราะชื่อแบบนั้นผูกชิ้นย่อยกับวิธีที่ parent จัดการ ถ้าวันหนึ่ง parent เปลี่ยนจากลบทันทีเป็นถามยืนยันก่อน ชื่อ onRemove ยังถูกต้อง แต่ deleteFromServer ผิดทันที
อีกข้อคือส่งค่าที่ผู้ใช้เลือกกลับมาเป็นค่าที่มีความหมาย ไม่ใช่ event ดิบของ DOM onQtyChange(3) ใช้ง่ายกว่า onChange(event) ที่ parent ต้องไปแกะ event.target.value แล้วแปลงเป็นตัวเลขเอง
component หลักเป็นที่เดียวที่ถือ state
เมื่อชิ้นย่อยทุกชิ้นไม่ดึงข้อมูลเอง ทุกอย่างจะมารวมที่ component หลัก หรือ orchestrator ที่เราพูดถึงในบทก่อน รูปร่างที่ได้คือ
- orchestrator เรียก hook ถือ state เรียก server action แล้วส่ง props ลงไป
- ชิ้นย่อย วาดหน้าจอและแจ้งเหตุการณ์ ไม่มี side effect
แบบนี้เวลาอยากรู้ว่าหน้าตะกร้าเปลี่ยนข้อมูลตอนไหนบ้าง ก็เปิดไฟล์เดียว คือ CartList กับ hook ที่ CartList เรียก ส่วนเวลาจะปรับหน้าตา ก็เปิดชิ้นย่อยโดยไม่ต้องกลัวไปกระทบ logic
ถ้าคุ้นกับคำว่า container กับ presentational component ของ Dan Abramov ปี 2015 แนวคิดใกล้กัน แต่ต่างกันตรงที่เราไม่สร้าง CartRowContainer ครอบทุกชิ้น ตัว Dan เองก็เขียนเพิ่มไว้ในบทความเดิมเมื่อปี 2019 ว่าไม่แนะนำให้แบ่งแบบนั้นเป็นกฎแล้ว เพราะ hook ทำให้ component หลักถือ logic เองได้โดยไม่ต้องมี wrapper เราจึงมี container แค่ตัวเดียวต่อหน้าจอ คือ orchestrator
ส่ง children แทนการส่ง props ผ่านหลายชั้น
ข้อเสียของการให้ชิ้นย่อยรับทุกอย่างทาง props คือบางทีต้องส่งค่าผ่านชั้นที่ไม่ได้ใช้ค่านั้นเลย เช่น CartPanel รับ onRemove มาเพื่อส่งต่อให้ CartList แล้วส่งต่ออีกทีให้ CartRow อาการนี้เรียกว่า prop drilling
ทางแก้ส่วนใหญ่ไม่ใช่การกลับไปให้ชิ้นในสุดอ่าน store เอง แต่เป็นการให้ชั้นกลางรับ children หรือ slot แทน แล้วให้ orchestrator ประกอบชิ้นในสุดเอง
// ก่อน: CartPanel ต้องรู้จัก props ของทุกชิ้นข้างใน
<CartPanel lines={lines} onQtyChange={setQty} onRemove={remove} total={total} />
// หลัง: CartPanel วาดแค่กรอบ ส่วนข้างในส่งมาเป็น children
<CartPanel footer={<CartTotal amount={total} />}>
{lines.map((line) => (
<CartRow key={line.id} line={line} onQtyChange={(q) => setQty(line.id, q)} onRemove={() => remove(line.id)} />
))}
</CartPanel>
CartPanel ตัวหลังไม่ต้องรู้ว่าข้างในมีอะไร แค่วาดกรอบ หัว และส่วนท้าย ถ้า slot เริ่มเยอะจนกลายเป็น props สิบกว่าตัว ก็ถึงเวลาใช้ compound component ซึ่งเป็นเรื่องของบท ลด props ด้วย compound component
ค่าที่ทั้งแอปใช้ร่วมกันอ่านจาก context ได้
กฎ "ชิ้นย่อยไม่อ่านอะไรจากข้างนอก" มีข้อยกเว้นที่สมเหตุสมผล คือค่าที่ทั้งแอปใช้ร่วมกัน ไม่ค่อยเปลี่ยน และไม่ใช่ข้อมูลของหน้าจอนั้น เช่น ธีม ภาษา รูปแบบสกุลเงิน หรือข้อมูลผู้ใช้ที่ล็อกอินอยู่ ค่าพวกนี้ถ้าส่งทาง props ทุกชั้นจะรกโดยไม่ได้อะไร และใน test ก็ครอบ provider ตัวเดียวได้ไม่ยาก
เส้นแบ่งที่ใช้ได้ดีคือ อ่านจาก context ได้ถ้าเป็นค่า "สภาพแวดล้อม" แต่ข้อมูลที่หน้าจอนั้นแสดงหรือแก้ไข เช่น รายการในตะกร้า ต้องมาทาง props
state ที่ย้ายลงไปให้ลูกต้องอยู่ได้นานเท่าเดิม
บางครั้งเราอยากย้าย state เล็ก ๆ ลงไปไว้ในชิ้นย่อยเลย เช่น state ว่าแถวนี้กางรายละเอียดอยู่หรือไม่ ทำได้ แต่ต้องรู้ว่า state ของ component อยู่ได้เท่าที่ component นั้น mount อยู่
- ถ้า parent ซ่อนชิ้นย่อยด้วยเงื่อนไข (
{open && <Details />}) state ข้างในจะหายทุกครั้งที่ปิด - ถ้าชิ้นย่อยมี
keyที่เปลี่ยนได้ React จะสร้างตัวใหม่และ state จะเริ่มใหม่
ถ้าของเดิม state อยู่ที่ parent แล้วผู้ใช้คาดว่าค่าจะคงอยู่ระหว่างเปิดปิด การย้ายลงไปให้ลูกจะเปลี่ยนพฤติกรรมโดยไม่มีใครตั้งใจ ตอน refactor ให้คง state ไว้ที่ parent แล้วส่งลงไปทาง props จะปลอดภัยกว่า
ชิ้นย่อยแบบนี้ test ง่าย
ข้อดีที่เห็นชัดที่สุดของชิ้นย่อยที่รับ props และส่ง callback คือ test ตรงไปตรงมา render ด้วย props ชุดหนึ่ง แล้วดูว่าหน้าจอแสดงถูก จากนั้นจำลองการคลิกแล้วดูว่า callback ได้รับค่าที่ควรได้ ไม่ต้อง mock store ไม่ต้อง mock network และไม่ต้องรอข้อมูลโหลด ส่วน test ของ orchestrator ก็เหลือแค่ตรวจว่าเหตุการณ์จากชิ้นย่อยไปเรียก hook หรือ action ที่ถูกตัว
ถ้ายังไม่เคยตั้ง test ให้ component React ลองอ่าน เขียน test ให้ component React ด้วย Vitest ได้ ตัวอย่างในนั้นเป็นโปรเจกต์ Laravel กับ Inertia แต่วิธี render แล้วตรวจผลเหมือนกัน
เมื่อไหร่ไม่ต้องแยกเป็นชิ้น
- markup ที่ใช้ที่เดียวและสั้น เช่น หัวข้อกับปุ่มสองปุ่ม แยกเป็น component แล้วต้องกระโดดไปอ่านอีกไฟล์โดยไม่ได้อะไร ทิ้งไว้ใน orchestrator ได้
- ชิ้นที่ต้องรับ props เกือบเท่าทุกอย่างของ parent แปลว่าเส้นแบ่งผิดที่ ลองแบ่งตามส่วนของหน้าจอแทน เช่น หัว เนื้อหา ส่วนท้าย
- ไม่ต้องห่อ callback ด้วย
useCallbackทุกตัว ลูกศรที่สร้างใหม่ทุก render อย่าง(q) => setQty(line.id, q)ไม่ใช่ปัญหาในแอปส่วนใหญ่ และถ้าเปิด React Compiler ไว้ compiler จะจัดการ memo ให้เอง เรื่อง compiler มีในบท ใช้ 'use memo' ให้ React Compiler ทำงานจริง
จากงานจริง
ในงาน refactor ร้านค้าออนไลน์ Next.js 16 กับ CMS ที่เป็น Next.js admin ใน pnpm monorepo ทุก component ที่เราแยกจบลงที่รูปร่างเดียวกัน คือ orchestrator หนึ่งตัวกับชิ้นย่อยหลายชิ้น เช่น ส่วนต่าง ๆ ของฟอร์ม dialog แถบเครื่องมือ และรายการคอลัมน์ของตาราง ชิ้นที่ทำงานยากที่สุดตอนแยกคือชิ้นที่เดิมเรียก store เองอยู่ลึก ๆ เพราะต้องดึงการเรียกนั้นขึ้นมาไว้ที่ orchestrator ก่อนถึงจะแยกได้ แต่พอแยกเสร็จ ชิ้นพวกนี้กลายเป็นส่วนที่เขียน test ง่ายที่สุดในโปรเจกต์ เพราะส่ง props แล้วดูผลได้ทันที
อ้างอิง
- React: Keeping Components Pure
- React: Passing Data Deeply with Context
- Dan Abramov: Presentational and Container Components (บทความปี 2015 พร้อมหมายเหตุปี 2019)






