วิธีแยก custom hook ทีละเรื่องจาก component ที่ใหญ่เกิน

จัด hook ใน component ที่ใหญ่เกินเป็นกลุ่มตามเรื่อง แต่ละกลุ่มที่มี state, effect และ handler ของเรื่องเดียวกันกลายเป็น custom hook ชื่อ useVerbNoun ที่คืนค่าออกมาไม่กี่อย่าง component ที่เหลือจะอ่านได้เหมือนสารบัญ

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

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

สารบัญบทเรียน บทที่ 6 จาก 14
ในบทนี้ 12 หัวข้อ
  1. component ที่ทำหลายเรื่องดูได้จากจำนวน hook
  2. จัดกลุ่ม hook ตามเรื่องก่อนลงมือแยก
  3. ตัวอย่างฟอร์มที่อยู่ก่อนและหลังแยก
  4. component หลักเหลือหน้าที่ประกอบชิ้น
  5. hook ควรคืนค่าออกมาให้น้อยที่สุด
  6. ใช้ useReducer เมื่อ state หลายตัวเปลี่ยนพร้อมกัน
  7. effect หลายตัวไม่จำเป็นต้องมี
  8. ลำดับของ hook คือพฤติกรรม
  9. แยกทีละ hook แล้วตรวจทุกครั้ง
  10. เมื่อไหร่ไม่ควรแยกเป็น hook
  11. จากงานจริง
  12. อ้างอิง

บทนี้เป็นท่าที่ใช้บ่อยที่สุดตอนแยก component ที่ใหญ่เกิน เกือบทุกไฟล์ที่ยาวหลายร้อยบรรทัดมี state กับ effect หลายกลุ่มกองรวมกันอยู่ด้านบน แล้วมี handler ที่แตะ state พวกนั้นกระจายอยู่ทั่วไฟล์ ถ้าแยกกลุ่มเหล่านี้ออกมาตั้งชื่อได้ ไฟล์จะสั้นลงทันที และที่สำคัญกว่าคืออ่านแล้วรู้ว่า component นี้ทำกี่เรื่อง

component ที่ทำหลายเรื่องดูได้จากจำนวน hook

ก่อนจะแยกอะไร ลองนับดูก่อน ตัวเลขที่เราใช้เป็นเกณฑ์ในบทเรียนนี้ (มาจากบท เกณฑ์ขนาดไฟล์และ component) มีสองระดับ ระดับแรกให้เริ่มระวัง ระดับที่สองให้แยก

วัดอะไร เริ่มระวัง ควรแยก
การเรียก hook ใน component เดียว 8 12
useState / useReducer ใน component เดียว 5 8
effect ใน component เดียว 2 4
ความยาวของ custom hook หนึ่งตัว 120 บรรทัด 200 บรรทัด

ตัวเลขอย่างเดียวยังไม่พอ อาการที่ชัดกว่าคือเลื่อนดูไฟล์แล้วเห็นโค้ดหลายที่ที่ไม่เกี่ยวกันแตะ state สามตัวเดิม เช่น handler ของปุ่มบันทึกเซ็ต saving effect ที่โหลดจังหวัดอ่านค่านี้ และปุ่มยกเลิกก็ใช้ค่านี้ปิดตัวเอง แบบนี้แปลว่าเรื่องหลายเรื่องพันกันอยู่ใน scope เดียว คนแก้เรื่องหนึ่งต้องอ่านทั้งไฟล์เพื่อให้แน่ใจว่าไม่ไปกระทบเรื่องอื่น

จัดกลุ่ม hook ตามเรื่องก่อนลงมือแยก

ขั้นแรกยังไม่ต้องแก้โค้ด ให้ไล่รายการ hook ทุกตัวในไฟล์ แล้วถามทีละตัวว่าอยู่เรื่องไหน เรื่องในที่นี้คือสิ่งที่ผู้ใช้หรือคนอ่านโค้ดเรียกชื่อได้ เช่น "ค่าในฟอร์ม" "ตัวเลือกจังหวัดกับอำเภอ" "การบันทึก" "ปุ่มลัดบนคีย์บอร์ด" กลุ่มหนึ่งมักมีสามส่วน

  • state ของเรื่องนั้น เช่น values, errors
  • effect ที่อ่านหรือเขียน state นั้น เช่น โหลดอำเภอเมื่อจังหวัดเปลี่ยน
  • handler ที่เปลี่ยน state นั้น เช่น setField, reset

ถ้าหลายกลุ่มใช้ state ตัวเดียวกัน ให้จดไว้ ตัวนั้นคือข้อมูลที่ต้องส่งจาก hook หนึ่งไปให้อีก hook ผ่าน argument ไม่ใช่สิ่งที่ hook สองตัวแชร์กันเงียบ ๆ

ลองดูฟอร์มที่อยู่จัดส่งในหัวข้อถัดไปเป็นตัวอย่าง พอไล่ hook ทีละตัวแล้วจดลงตาราง จะได้แบบนี้

เรื่อง state effect handler
ค่าในฟอร์ม values, errors – setField, validate, reset
ตัวเลือกพื้นที่ provinces, districts, loadingGeo โหลดจังหวัดครั้งแรก, โหลดอำเภอเมื่อจังหวัดเปลี่ยน –
การบันทึก saving – onSubmit

ตารางนี้บอกอีกเรื่องด้วย คือ "ตัวเลือกพื้นที่" ต้องรู้จังหวัดที่ผู้ใช้เลือก ซึ่งอยู่ใน "ค่าในฟอร์ม" เส้นทางของข้อมูลจึงเป็นทางเดียว จาก values.province ไปหา hook ตัวเลือกพื้นที่ และ "การบันทึก" ต้องอ่านค่าในฟอร์มทั้งหมด ถ้าเจอเรื่องที่ต้องอ่านค่าของกันและกันไปมาทั้งสองทาง แปลว่าแบ่งเรื่องผิด ลองรวมสองกลุ่มนั้นเป็นเรื่องเดียวก่อน

จัดกลุ่มเสร็จแล้วจะเห็นภาพทันทีว่า component นี้ทำกี่เรื่อง ส่วนใหญ่ไฟล์ที่ยาว 400–800 บรรทัดจะแบ่งได้ 3–5 กลุ่ม แต่ละกลุ่มกลายเป็น hook หนึ่งตัว

ตัวอย่างฟอร์มที่อยู่ก่อนและหลังแยก

ฟอร์มที่อยู่จัดส่งเป็นตัวอย่างที่เจอบ่อย มีค่าที่ผู้ใช้กรอก ตัวเลือกจังหวัดกับอำเภอที่ต้องโหลดตามกัน และการบันทึก เดิมทุกอย่างอยู่ใน component เดียว

// ก่อน: state 9 ตัว effect 3 ตัว อยู่ใน component เดียวยาว 400 บรรทัด
function AddressForm({ initial, onSaved }: Props) {
  const [values, setValues] = useState(initial ?? empty);
  const [errors, setErrors] = useState<FieldErrors>({});
  const [provinces, setProvinces] = useState<Province[]>([]);
  const [districts, setDistricts] = useState<District[]>([]);
  const [loadingGeo, setLoadingGeo] = useState(false);
  const [saving, setSaving] = useState(false);
  useEffect(() => { fetchProvinces().then(setProvinces); }, []);
  useEffect(() => {
    if (!values.province) return;
    setLoadingGeo(true);
    fetchDistricts(values.province).then(setDistricts).finally(() => setLoadingGeo(false));
  }, [values.province]);
  // …handler อีก 10 ตัวกับ JSX อีก 300 บรรทัด
}

// หลัง: component หลักแค่ต่อเรื่องที่มีชื่อเข้าด้วยกัน
function AddressForm({ initial, onSaved }: Props) {
  const form = useAddressFields(initial);            // values, errors, setField
  const geo = useGeoOptions(form.values.province);   // provinces, districts, loading
  const submit = useAddressSubmit(form, onSaved);    // onSubmit, disabled, saving
  return (
    <form onSubmit={submit.onSubmit}>
      <AddressFields form={form} geo={geo} />
      <SubmitBar disabled={submit.disabled} saving={submit.saving} />
    </form>
  );
}

function useGeoOptions(province: string | undefined) {
  const { data: provinces = [] } = useSWR('provinces', fetchProvinces);
  const { data: districts = [], isLoading } = useSWR(
    province ? ['districts', province] : null,
    () => fetchDistricts(province!),
  );
  return { provinces, districts, loading: isLoading };
}

ดูที่ AddressForm ตัวหลัง อ่านจากบนลงล่างได้ว่าฟอร์มนี้มีสามเรื่อง ค่าในฟอร์ม ตัวเลือกพื้นที่ และการบันทึก ถ้าวันหนึ่งต้องเพิ่มตำบล ก็รู้ทันทีว่าต้องไปแก้ที่ useGeoOptions ไม่ต้องไล่อ่านทั้ง 400 บรรทัด

useGeoOptions ในตัวอย่างใช้ SWR แทน effect ที่เขียนเอง เพราะการโหลดข้อมูลตาม key เป็นงานที่ library ทำได้ดีกว่า และได้ cache กับ loading state มาด้วย แต่การเปลี่ยนจาก effect เป็น SWR เป็นการเปลี่ยนพฤติกรรม (เช่น โหลดซ้ำตอนสลับแท็บกลับมา) ถ้ากำลัง refactor ให้ย้าย effect เดิมไปไว้ใน hook ก่อน แล้วค่อยเปลี่ยนเป็น SWR ใน commit แยก

component หลักเหลือหน้าที่ประกอบชิ้น

หลังแยก hook ออกไปหมด component เดิมจะเหลือหน้าที่เดียว คือเป็นคนกลางที่เรียก hook แต่ละตัว ส่งค่าจากตัวหนึ่งไปอีกตัว แล้วส่ง props ลงไปให้ component ย่อยที่วาดหน้าจอ ในบทเรียนนี้เราเรียก component แบบนี้ว่า orchestrator

orchestrator ที่ดีมีลักษณะแบบนี้

  • อ่านจากบนลงล่างได้เหมือนสารบัญ บรรทัดละเรื่อง ชื่อ hook บอกว่าเรื่องนั้นคืออะไร
  • ไม่มี logic ของตัวเอง ถ้ายังมี if ยาว ๆ หรือการคำนวณอยู่ แปลว่ายังมีเรื่องที่ไม่ได้ตั้งชื่อ
  • เก็บ path เดิมไว้ คนที่ import AddressForm อยู่ไม่ต้องแก้อะไร ไฟล์ hook ใหม่วางไว้ข้าง ๆ ในโฟลเดอร์เดียวกัน

ส่วนที่วาดหน้าจอ (AddressFields, SubmitBar) แยกออกเป็น component ย่อยที่รับข้อมูลทาง props แล้วส่งเหตุการณ์กลับทาง callback ซึ่งเป็นเรื่องของบท แยก component ย่อยที่รับ props และส่ง callback ออก

hook ควรคืนค่าออกมาให้น้อยที่สุด

ตั้งชื่อ hook เป็น use + กริยา + คำนาม เช่น useGeoOptions, useAddressSubmit, useKeyboardShortcuts ชื่อแบบนี้บอกว่า hook ทำอะไร ไม่ใช่บอกว่าข้างในมี state อะไร (useAddressState บอกน้อยกว่ามาก)

ค่าที่ hook คืนออกมาคือ interface ของเรื่องนั้น ให้คืนเท่าที่คนเรียกต้องใช้

  • คืนเป็น object ที่มีชื่อ ({ provinces, districts, loading }) ถ้ามีค่ามากกว่าสองตัว คนเรียกจะได้หยิบด้วยชื่อ ไม่ต้องจำลำดับ
  • ไม่ต้องคืน setState ดิบออกไปทุกตัว คืน handler ที่มีความหมายแทน เช่น setField(name, value) หรือ reset() hook จะได้คุมว่า state เปลี่ยนได้แบบไหนบ้าง
  • ข้อมูลที่ hook ต้องรู้จากเรื่องอื่น ให้รับเป็น argument อย่าง useGeoOptions(form.values.province) อย่าให้ hook ไปอ่าน store หรือ context เองเพื่อเอาค่าที่คนเรียกมีอยู่แล้ว

ใช้ useReducer เมื่อ state หลายตัวเปลี่ยนพร้อมกัน

บางกลุ่มมี state สี่ห้าตัวที่ต้องเปลี่ยนพร้อมกันทุกครั้ง เช่น ตอนบันทึกต้องเซ็ต saving เป็น true ล้าง error และจำ lastSavedAt ถ้าเขียนเป็น useState แยกกัน handler ทุกตัวต้องจำให้ครบว่าต้องเซ็ตอะไรบ้าง ลืมตัวเดียวหน้าจอก็ค้างอยู่ในสถานะที่ไม่ควรเกิด เช่น ยังหมุนอยู่ทั้งที่ขึ้น error แล้ว

ใน hook ของเรื่องนั้น ให้รวม state พวกนี้เป็น useReducer ตัวเดียว แล้วตั้งชื่อเหตุการณ์ที่เกิดขึ้นแทนการเซ็ตค่าทีละตัว

type SaveState = { status: 'idle' | 'saving' | 'saved' | 'failed'; error?: string; savedAt?: Date };
type SaveEvent = { type: 'start' } | { type: 'done'; at: Date } | { type: 'fail'; error: string };

function saveReducer(state: SaveState, event: SaveEvent): SaveState {
  switch (event.type) {
    case 'start': return { status: 'saving' };
    case 'done': return { status: 'saved', savedAt: event.at };
    case 'fail': return { status: 'failed', error: event.error };
  }
}

function useAddressSubmit(form: AddressFields, onSaved: (a: Address) => void) {
  const [save, dispatch] = useReducer(saveReducer, { status: 'idle' });
  async function onSubmit(e: React.FormEvent) {
    e.preventDefault();
    dispatch({ type: 'start' });
    try { onSaved(await saveAddress(form.values)); dispatch({ type: 'done', at: new Date() }); }
    catch (err) { dispatch({ type: 'fail', error: messageOf(err) }); }
  }
  return { onSubmit, saving: save.status === 'saving', error: save.error };
}

saveReducer เป็นฟังก์ชันล้วน ไม่ต้องมี React ก็ test ได้ทุกกรณี และหน้าจอเข้าสถานะ "หมุนอยู่พร้อมขึ้น error" ไม่ได้อีก เพราะ type ไม่มีสถานะนั้นให้เลือก เรื่องฟังก์ชันล้วนมีบทของตัวเองใน แยกฟังก์ชันล้วนออกจาก component ให้ test ได้

effect หลายตัวไม่จำเป็นต้องมี

ตอนจัดกลุ่ม hook ลองถาม effect ทุกตัวว่าควรมีอยู่จริงไหม เอกสารของ React มีหน้า You Might Not Need an Effect ที่สรุปไว้ดี effect ที่เจอบ่อยว่าไม่จำเป็นมีสองแบบ

  • effect ที่คำนวณค่าจาก state อื่น เช่น เซ็ต fullAddress ทุกครั้งที่ values เปลี่ยน ค่าแบบนี้คำนวณตอน render ได้เลย ไม่ต้องเก็บเป็น state
  • effect ที่จริง ๆ เป็นผลของเหตุการณ์ เช่น เมื่อ saved เป็น true ให้ปิด dialog แบบนี้ย้ายไปไว้ใน handler ที่ทำให้บันทึกสำเร็จจะตรงกว่า
// ก่อน: state ที่ต้องคอยซิงก์
const [fullAddress, setFullAddress] = useState('');
useEffect(() => { setFullAddress(formatAddress(values)); }, [values]);

// หลัง: คำนวณตอน render
const fullAddress = formatAddress(values);

แต่การลบ effect ทิ้งเป็นการเปลี่ยนพฤติกรรม ถึงจะเป็นการเปลี่ยนที่ดีก็ตาม เช่น ตัวเดิมอาจ render สองรอบแล้วมี component อื่นพึ่งรอบที่สองอยู่ ให้ทำใน commit แยกจากการย้ายโค้ด จะได้รีวิวทีละเรื่อง

ลำดับของ hook คือพฤติกรรม

React เรียก effect ตามลำดับที่ประกาศ และ effect ของ component ลูกจะทำงานก่อนของ component แม่ ถ้าย้าย hook ไปมาโดยไม่ระวัง ลำดับนี้จะเปลี่ยน เช่น effect ที่ subscribe event กับ effect ที่อ่านค่าเริ่มต้นจาก URL ถ้าสลับกัน อาจได้ event แรกก่อนที่ค่าจาก URL จะเข้ามา

วิธีกันง่าย ๆ คือ

  • เรียก hook ใหม่ใน orchestrator ตามลำดับเดียวกับกลุ่มเดิมในไฟล์ ถ้ากลุ่มโหลดจังหวัดเคยอยู่ก่อนกลุ่มบันทึก ก็เรียก useGeoOptions ก่อน useAddressSubmit
  • จดลำดับ effect ก่อนและหลังแยกไว้ในคำอธิบายของ PR ให้คนรีวิวเทียบได้
  • ระวัง hook สำเร็จรูปที่มี effect ซ่อนอยู่ข้างใน เช่น useSWR หรือ hook คัดลอกข้อความ ย้าย hook ที่เรียก hook พวกนี้เมื่อไหร่ effect ข้างในก็ย้ายตาม

แยกทีละ hook แล้วตรวจทุกครั้ง

ตอนลงมือ อย่าแยกทุกกลุ่มในรอบเดียว ทำทีละ hook จะรีวิวง่ายกว่าและหาสาเหตุได้ง่ายกว่าถ้ามีอะไรเพี้ยน

  1. ย้ายกลุ่มแรกออกเป็น hook ในไฟล์ใหม่ข้าง ๆ component ตั้งชื่อไฟล์ตาม hook เช่น use-geo-options.ts
  2. ย้ายโค้ดไปตามเดิมทุกบรรทัด ไม่แก้ชื่อตัวแปร ไม่เปลี่ยน effect เป็น library ไม่ลบอะไรทิ้ง
  3. เรียก hook ใหม่ใน orchestrator ตรงตำแหน่งเดิมของกลุ่มนั้น
  4. รัน typecheck, lint และ test แล้วลองใช้หน้านั้นจริงสักรอบ
  5. commit แล้วค่อยไปกลุ่มถัดไป

ส่วนการปรับปรุงที่เห็นระหว่างทาง เช่น effect ที่ไม่จำเป็น หรือชื่อตัวแปรที่ควรเปลี่ยน ให้จดไว้แล้วทำใน commit หลังจากย้ายเสร็จหมดแล้ว การย้ายกับการแก้ปนอยู่ใน diff เดียวกันรีวิวยากมาก เรื่องนี้ขยายต่อในบท refactor component ที่ใหญ่เกิน

เมื่อไหร่ไม่ควรแยกเป็น hook

  • state ตัวเดียวที่ไม่มี effect ห่อ useState ตัวเดียวเป็น hook ได้ประโยชน์น้อย ทิ้งไว้ใน orchestrator อ่านง่ายกว่า
  • อยากย้าย state ลงไปให้ component ลูก hook ไม่ได้ย้าย state ไปไหน state ยังอยู่กับ component ที่เรียก hook เหมือนเดิม ถ้าตั้งใจให้ลูกเป็นเจ้าของ state จริง ต้องแน่ใจว่าลูกตัวนั้น mount อยู่ตลอดและไม่มี key ที่เปลี่ยน ไม่อย่างนั้น state จะหายทุกครั้งที่ลูก unmount
  • hook ที่ยาวเกิน 200 บรรทัด แปลว่าในนั้นมีสองเรื่อง แยกต่ออีกรอบ อย่าให้ hook กลายเป็นที่ซ่อนความยุ่งแทน component

จากงานจริง

ในร้านค้าออนไลน์ Next.js 16 กับ CMS ที่เป็น Next.js admin ใน pnpm monorepo ที่เรา refactor ไฟล์ใหญ่ไปราว 70 ไฟล์ การแยก hook เป็นท่าที่ใช้บ่อยที่สุด เกือบทุก component ที่แยกได้ hook ออกมา 1–4 ตัว เช่น state ของฉบับร่าง ข้อความที่กลับมาจาก OAuth การยืนยันตัวตน ปุ่มลัด และการคำนวณตำแหน่งของแถบด้านข้าง ฟอร์มแก้ไขบทความที่เคยยาวสองพันกว่าบรรทัดเหลือราวหกร้อยบรรทัด โดยไม่ได้ลบความสามารถอะไรออกเลย โค้ดที่เคยปนกันอยู่ย้ายไปอยู่ใน hook ที่มีชื่อบอกว่าทำอะไร เรื่องที่รีวิวต้องตรวจซ้ำแทบทุกครั้งคือลำดับของ effect และ effect ที่ติดมากับ hook สำเร็จรูป

อ้างอิง

บทถัดไป

อ่านต่อ

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

ล่าสุด

ดูทั้งหมด →