วิธี refactor component React ที่ใหญ่เกินด้วย Claude Code

แยก component ที่ใหญ่เกินเป็น PR ที่ย้ายโค้ดอย่างเดียว เริ่มจากฟังก์ชันล้วนและ component ย่อย แล้วค่อยแยก hook จนไฟล์เดิมเหลือแค่ประกอบชิ้นส่วน พิสูจน์ว่าพฤติกรรมเหมือนเดิมด้วย parity test ที่ commit ไว้ก่อน การเทียบทีละ declaration และลำดับของ effect แล้วให้ agent หลาย session แยกไฟล์ที่ไม่ซ้ำกันและรวมเข้า main ทีละชุด

เผยแพร่
เวลาอ่าน
7 นาที

หมวด 4 · Refactor ให้ปลอดภัยบทที่ 12 จาก 14

สารบัญบทเรียน บทที่ 12 จาก 14
ในบทนี้ 7 หัวข้อ
  1. PR ที่ refactor ย้ายโค้ดอย่างเดียว
  2. แยกไฟล์ก่อนเพิ่มโค้ด
  3. ลำดับการแยก
  4. test ผ่านไม่ได้แปลว่าย้ายถูก
  5. พลาดบ่อยตอนแยก
  6. ใช้ AI agent แยกทีละชุด
  7. จากงานจริง

ไฟล์ที่เกินเกณฑ์ตามวิธีตั้งเกณฑ์ขนาดไฟล์และ component ใน React ต้องแยก บทนี้ว่าด้วยวิธีแยกให้พฤติกรรมเหมือนเดิมทุกอย่าง ใช้ได้ทั้งตอนแยกเองและตอนให้ Claude Code หรือ agent ตัวอื่นแยกให้

PR ที่ refactor ย้ายโค้ดอย่างเดียว

กฎข้อแรกที่สำคัญที่สุด PR ที่แยกไฟล์ห้ามเปลี่ยนพฤติกรรม ห้ามแก้ข้อความหรือลำดับ class ห้ามเปลี่ยนชื่อ prop หรือ export และห้ามเปลี่ยน signature ของฟังก์ชัน ถ้าเจอ bug ระหว่างทาง ให้จดแยกไว้แล้วแก้ใน PR ของตัวเอง

เหตุผลคือ PR ที่ทั้งย้ายและแก้ในคราวเดียว review ไม่ได้ คนตรวจเห็นโค้ดสองพันบรรทัดย้ายที่ แยกไม่ออกว่าบรรทัดไหนแค่ย้ายมา บรรทัดไหนเปลี่ยนจริง พอเป็นการย้ายล้วน ๆ คนตรวจก็ตั้งคำถามข้อเดียวได้ว่า "มีอะไรที่ไม่เหมือนเดิมบ้าง" และทุกข้อที่ไม่เหมือนเดิมต้องมีคำอธิบายใน PR

กฎ performance ก็อยู่นอก PR แบบนี้เหมือนกัน เช่น useMemo ที่ควรมี หรือ await ที่ควรรันพร้อมกัน เพราะเปลี่ยนว่าโค้ดรันอะไรหรือรันเมื่อไหร่ ให้จดไว้แล้วแก้ใน PR ถัดไป

แยกไฟล์ก่อนเพิ่มโค้ด

ไฟล์ที่เกินเกณฑ์มักไม่ได้โตเพราะใครตั้งใจ แต่โตทีละ feature งานละห้าสิบบรรทัด แต่ละครั้งก็ดูไม่มากพอจะแยก ทางกันคือกฎว่าก่อนเพิ่มโค้ดในไฟล์ที่เกินเกณฑ์แล้ว ให้แยกก่อน ใน change ของตัวเอง แล้วค่อยเขียน feature บนไฟล์ที่สะอาดแล้ว

ถ้าคิดว่าจะเขียน feature ก่อนแล้วค่อยแยกทีหลัง ส่วนใหญ่ "ทีหลัง" จะไม่มาถึง และ diff ที่ทั้งเพิ่ม feature และย้ายโค้ดก็กลับไปเป็น PR ที่ review ไม่ได้อีก

ลำดับการแยก

เริ่มจากส่วนที่เสี่ยงน้อยแต่ทำให้ไฟล์สั้นลงมาก แต่ละขั้น typecheck และรัน test ได้ในตัว

  1. ไล่ทั้งไฟล์ก่อนลงมือ จัดทุกก้อน ทั้ง state, ref, memo, effect, callback, helper และ component ย่อย ว่าจะไปอยู่ไหน หรืออยู่ที่เดิม ร่างชื่อไฟล์ปลายทางไว้ใน PR ก่อนย้ายอะไร
  2. ฟังก์ชันล้วนและค่าคงที่ ที่ไม่ได้ใช้ hook ย้ายไป lib/ หรือโฟลเดอร์ของ feature ไม่มีอะไรเสี่ยงเลย
  3. component ย่อยที่เขียนไว้ท้ายไฟล์ ย้ายออกไปทั้งก้อนตามเดิม เพิ่ม export แล้ว import กลับ
  4. กลุ่ม hook ที่ใหญ่ที่สุด กลายเป็น custom hook ตัวแรก เก็บ dependency array ไว้ตามเดิมทุกตัว
  5. กลุ่มที่เหลือ แยกเป็น hook ละเรื่อง hook ที่รับ argument ห้าตัวที่ไม่เกี่ยวกันคือสอง hook
  6. JSX ก้อนใหญ่ เช่น แท็บ ตัวแก้รายการ หรือเนื้อ dialog แยกเป็น component ที่รับ props และส่ง callback ออก
  7. ไฟล์เดิมเหลือเป็นตัวประกอบ คือรับ props เรียก hook แล้ว render โครง JSX

การดัน 'use client' ลงไปให้ต่ำลง กับการเปลี่ยน props เป็น compound component เป็นการเปลี่ยน API หรือเปลี่ยนว่าอะไร render ที่ไหน ให้แยกเป็น PR ของตัวเองหลังจากนี้ วิธีทำอยู่ในบทServer Componentและบทcompound component

ตัวอย่างเป็นหน้าแก้สินค้าที่ทุกอย่างอยู่ในไฟล์เดียว

// ก่อน: product-editor.tsx ราว 900 บรรทัด
'use client'

export function ProductEditor({ product }: { product: Product }) {
  const [draft, setDraft] = useState(product)
  const [dirty, setDirty] = useState(false)
  const [saving, setSaving] = useState(false)
  const [images, setImages] = useState(product.images)
  const [uploading, setUploading] = useState(false)

  useEffect(() => { /* เตือนก่อนปิดแท็บถ้ายังไม่บันทึก */ }, [dirty])
  useEffect(() => { /* กด Ctrl+S เพื่อบันทึก */ }, [draft])

  function formatPrice(value: number) { /* ... */ }
  async function save() { /* ... */ }
  async function upload(files: FileList) { /* ... */ }

  return (
    <form>
      {/* หัวฟอร์มกับปุ่มบันทึก ราว 120 บรรทัด */}
      {/* ราคาและสต็อก ราว 200 บรรทัด */}
      {/* รูปสินค้า ราว 250 บรรทัด */}
    </form>
  )
}

// หลัง: path และชื่อ export เดิม ไฟล์เหลือแค่ประกอบชิ้นส่วน
'use client'

import { useUnsavedWarning } from '@/hooks/use-unsaved-warning'
import { EditorHeader } from './product-editor/editor-header'
import { ImageSection } from './product-editor/image-section'
import { PricingSection } from './product-editor/pricing-section'
import { useImageUpload } from './product-editor/use-image-upload'
import { useProductDraft } from './product-editor/use-product-draft'
import { useSaveShortcut } from './product-editor/use-save-shortcut'

export function ProductEditor({ product }: { product: Product }) {
  const { draft, update, dirty, save, saving } = useProductDraft(product)
  const images = useImageUpload(product.images)
  useUnsavedWarning(dirty)
  useSaveShortcut(save)

  return (
    <form>
      <EditorHeader title={draft.name} saving={saving} onSave={save} />
      <PricingSection value={draft} onChange={update} />
      <ImageSection images={images.list} uploading={images.uploading} onUpload={images.upload} />
    </form>
  )
}

สิ่งที่ตั้งใจให้เหมือนเดิมมีสามเรื่อง

  • path กับ export เดิมยังอยู่ ไฟล์ที่ import ProductEditor อยู่ไม่ต้องแก้อะไร ชิ้นที่แยกออกมาไปอยู่ในโฟลเดอร์ชื่อเดียวกับ component
  • ลำดับ effect เหมือนเดิม เดิมเตือนปิดแท็บมาก่อนคีย์ลัด useUnsavedWarning จึงต้องเรียกก่อน useSaveShortcut (หัวข้อถัดไปบอกว่าทำไมต้องสนใจ)
  • formatPrice ไม่ได้อยู่ใน hook ไหน ย้ายไป lib/format-price.ts เป็นฟังก์ชันล้วน เพราะไม่มี hook ข้างใน ถ้าตั้งชื่อขึ้นต้นด้วย use ก็มีแต่ได้กฎของ hook เพิ่มมาเปล่า ๆ

test ผ่านไม่ได้แปลว่าย้ายถูก

typecheck, lint และ test บอกได้แค่ว่าโค้ดยัง build ได้ และเรื่องที่มี test ครอบยังเหมือนเดิม แต่เวลาย้ายโค้ดมีหลายอย่างที่พังได้โดยไม่มี test ไหนแดง เช่น การเช็กสิทธิ์ที่หลุดหายไประหว่างย้าย effect ที่สลับลำดับ หรือเงื่อนไขที่แคบลง ข้อพวกนี้ต้องตรวจเพิ่มเอง

เขียน parity test ก่อน แล้ว commit แยกไว้

parity test คือ test ที่จับผลลัพธ์ตอนนี้ไว้ เพื่อให้รู้ทันทีถ้าผลลัพธ์เปลี่ยน ลำดับคือเขียน test ที่ render component เป็น HTML แล้วเก็บเป็น snapshot ไว้บนโค้ดเดิม commit ไว้ก่อน จากนั้นค่อยแยกไฟล์ ถ้า snapshot ไม่เปลี่ยน ก็แปลว่า HTML ออกมาเหมือนเดิม

import { renderToStaticMarkup } from 'react-dom/server'
import { expect, test } from 'vitest'
import { ProductEditor } from './product-editor'
import { productWithImages, productWithoutStock } from './fixtures'

test.each([
  ['มีรูปและสต็อก', productWithImages],
  ['สินค้าหมด', productWithoutStock],
])('ProductEditor แสดงผลเหมือนเดิม: %s', (_, product) => {
  expect(renderToStaticMarkup(<ProductEditor product={product} />)).toMatchSnapshot()
})

test นี้ต้องอยู่ใน commit ก่อนการแยก คนตรวจจะ checkout commit นั้นแล้วรันซ้ำบนโค้ดเดิมได้ ถ้าเขียน harness ไว้เทียบบนเครื่องแล้วทิ้งไป ก็ไม่มีใครรันซ้ำได้อีก ใส่ข้อมูลหลายแบบให้ครบทุกกิ่งที่หน้าตาต่างกัน เช่น มีรูปกับไม่มีรูป หรือเปิดกับปิด feature flag

HTML snapshot ไม่ครอบการคลิก effect ไม่รันตอน renderToStaticMarkup และ state ที่เปลี่ยนหลังผู้ใช้กดปุ่มก็ไม่อยู่ใน HTML ปุ่มที่สำคัญ เช่น บันทึก หรือลบ ให้เพิ่ม test ที่กดจริงด้วย Testing Library ไว้ใน commit เดียวกัน

เทียบโค้ดทีละ declaration ไม่ใช่เทียบทั้งไฟล์

diff ทั้งไฟล์มองไม่เห็นบรรทัดที่ย้ายจากฟังก์ชันหนึ่งไปอยู่อีกฟังก์ชันหนึ่ง เช่น revalidatePath หรือการเช็กสิทธิ์ที่ย้ายเจ้าของไป แต่จำนวนบรรทัดยังเท่าเดิม ให้เทียบทีละ declaration ระดับบนสุด คือทุกฟังก์ชัน component และค่าคงที่ ระหว่างของเดิมกับของใหม่ ส่วนที่ควรต่างกันมีแค่บรรทัด import

สิ่งที่ไม่เหมือนเดิมทุกตัวอักษรให้เขียนไว้ใน PR พร้อมเหตุผลว่าทำไมยังเท่ากัน เช่น dependency array ที่เพิ่ม setter ซึ่ง React รับประกันว่าไม่เปลี่ยน ถ้าเป็นการเขียนใหม่ เช่น เปลี่ยนเงื่อนไขหลายบรรทัดให้เป็นฟังก์ชันล้วน ก็นับเป็น refactor ไม่ใช่การย้าย ให้แยก commit และมี test ของตัวเอง

ข้อความที่ render ก็เทียบแบบเดียวกันได้ ดึงทุก string, className, key, id และ aria-* จากไฟล์เดิมกับไฟล์ใหม่ทั้งหมด แล้วเทียบสองชุดนี้ ถ้ามีคำไหนเปลี่ยนก็จะเห็นทันที

ลำดับของ hook และ effect

effect รันตามลำดับที่ประกาศ และ effect ของ component ลูกรันก่อนของ component แม่ ถ้า effect สองตัวแตะสิ่งเดียวกัน เช่น focus, scroll หรือ listener บน window การสลับลำดับก็เปลี่ยนพฤติกรรมได้ ให้เขียนลำดับ hook และ effect ทั้งก่อนและหลังแยกไว้ใน PR

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

ย้าย server action แล้วได้ ID ใหม่

Next.js ระบุ server action แต่ละตัวด้วย action ID ที่สร้างตอน build ตามเอกสารของ Next.js แต่ละ deploy มักได้ ID ใหม่อยู่แล้ว และ Next หมุน ID อย่างน้อยทุก 14 วันแม้โค้ดไม่เปลี่ยน แต่การย้าย action ไปไฟล์อื่นทำให้ ID เปลี่ยนแน่นอนในรอบ deploy ถัดไป คนที่เปิดหน้าหลังบ้านค้างไว้ข้าม deploy จะกดปุ่มนั้นไม่ได้ ขึ้น "Failed to find Server Action" จนกว่าจะ reload หน้า

ข้อนี้ไม่ใช่ bug ใหม่ แต่ควรเขียนไว้ใน PR ให้คนที่ deploy รู้ว่าอาจมีคนถาม ส่วนวิธีวางไฟล์ 'use server' ให้ย้ายง่ายอยู่ในบทแยก server action และ data access

React Compiler อาจทำงานต่างไปหลังแยก

ถ้าโปรเจกต์ใช้ React Compiler แบบ annotation mode การแยกไฟล์อาจทำให้ compiler เริ่มหรือหยุด compile บางฟังก์ชันได้ โดยไม่มี warning ให้รัน compiler กับโค้ดก่อนและหลังแล้วบอกใน PR ว่าฟังก์ชันไหน compile บ้าง รายละเอียดอยู่ในบท'use memo'

พลาดบ่อยตอนแยก

  • เรียก hook ที่แยกออกมาไว้ใต้เงื่อนไข hook ต้องอยู่บนสุดของ component เสมอ ถ้าเจอ error "Rendered fewer hooks than expected" แปลว่ามีตัวหนึ่งไปอยู่หลัง if หรือหลัง return
  • ค่าเริ่มต้นของ useState กลายเป็นการเรียกฟังก์ชัน useState(initialValues(field)) รันทุกครั้งที่ render ให้ใช้ useState(() => initialValues(field))
  • ใส่ 'use client' ทุกไฟล์ที่แยกออกมา ไฟล์ที่มีแต่ client component import ไม่ต้องมี directive ถ้าใส่ ไฟล์นั้นจะกลายเป็นทางเข้าฝั่ง client อีกทาง
  • เปลี่ยนชื่อ "ไหน ๆ ก็ย้ายแล้ว" ชื่อ component หรือ prop ที่ test, script หรือคนอื่น grep หาอยู่จะหาไม่เจอ การเปลี่ยนชื่อเป็นงานอีกชิ้น
  • comment ที่ชี้ตำแหน่ง อย่าง "ฟังก์ชันข้างล่าง" หรือ "ในไฟล์นี้" ไม่จริงอีกต่อไปหลังย้าย ให้แก้ให้ชี้ถูกที่
  • type ทำให้ import วนกัน component ย่อยที่ import type ของ props กลับมาจากตัวประกอบจะวนกันในไม่ช้า type ที่ใช้ร่วมกันให้แยกไว้ไฟล์ของตัวเอง
  • hook ตัวเดียวที่คืนค่ายี่สิบตัว ไฟล์สั้นลง แต่ความซับซ้อนแค่ย้ายที่ไป

ใช้ AI agent แยกทีละชุด

agent แยกไฟล์ได้ดี เพราะงานนี้เป็นงานที่มีขั้นตอนชัดและตรวจได้ แต่ถ้ามีหลายสิบไฟล์ วิธีแบ่งงานก็สำคัญพอกับตัวขั้นตอน

แบ่งไฟล์ให้ agent หลาย session แล้วรวมเข้า main ทีละชุด

แต่ละ session ได้ไฟล์ที่ไม่ซ้ำกับ session อื่น แต่ละไฟล์ย้ายโค้ดอย่างเดียว แล้ว session กลางรวมทั้งชุด ตรวจ และ push ทีเดียว

  • แบ่งไฟล์ไม่ให้ซ้ำกัน ไฟล์ที่เกี่ยวกัน เช่น ตัวแก้ field ทั้งกลุ่ม ให้อยู่กับ session เดียว แต่ละ session ทำใน git worktree ของตัวเอง ไฟล์ไม่ชนกัน รวมกันลำดับไหนก็ได้
  • ตกลงชื่อของชิ้นที่ใช้ร่วมกันก่อน ไม่อย่างนั้นไฟล์ข้าง ๆ กันจะได้ FieldDeleteDialog กับ DeleteFieldConfirm ที่ทำเรื่องเดียวกันมาคู่กัน
  • ให้คนตรวจที่ไม่ได้รู้ข้อสรุปของคนเขียน agent ที่ตรวจ PR ควรได้แค่โค้ดเดิม โค้ดใหม่ และ checklist ไม่ได้คำอธิบายว่า "ย้ายอย่างเดียว ไม่มีอะไรเปลี่ยน" ไม่อย่างนั้นก็มักตรวจแล้วเห็นตาม
  • รวมทั้งชุดแล้วรัน CI ครั้งเดียว แต่ละ session commit ลง branch ของตัวเองบนเครื่อง session กลางรวมทุก branch รันขั้นตอนเดียวกับ CI ครบทุกชุด แล้ว push เข้า main ทีเดียว deploy หนึ่งรอบต่อหนึ่งชุด
  • รัน test ทั้งชุดบน branch ที่รวมแล้ว test บางตัวไม่ได้อ่านไฟล์ที่เราแก้ แต่ไล่อ่านโค้ดทั้งโปรเจกต์ เช่น test ที่ห้าม import path เก่า รันแค่ test ของไฟล์ที่แก้จึงไม่เจอ

ระหว่างทาง ถ้า session ไหนเจอกับดักที่คนอื่นน่าจะเจอด้วย เช่น test ที่อ้าง path ของไฟล์ที่กำลังจะย้าย ก็เขียนโน้ตไว้ในโฟลเดอร์กลางให้ session อื่นอ่าน

จากงานจริง

เราใช้ขั้นตอนนี้กับร้านค้าออนไลน์ Next.js 16 กับ CMS ที่เป็น Next.js admin ใน pnpm monorepo ตอนเริ่ม วัดได้ 367 จาก 2,517 ไฟล์ที่เกินเกณฑ์ แยกไปสี่ชุด ราว 70 ไฟล์ ตัวแก้บทความลดจาก 2,113 เหลือ 651 บรรทัด โครงหลักของหลังบ้านจาก 2,452 เหลือ 457 และตารางข้อมูลที่ใช้ทั้งหลังบ้านลดจาก 45 props เหลือ 11 แต่ละชุดผ่าน test ทั้งหมดราว 14,600 ตัวก่อน push และ deploy ผ่านทุกรอบ ระหว่างย้ายเจอ bug เดิมราว 15 จุด เช่น หน้าตั้งค่าที่บันทึกค่าเก่าเพราะ stale closure หรือ spinner ที่ค้างหลัง action ล้มเหลว ทั้งหมดจดไว้แก้ทีหลังโดยไม่แก้ใน PR ที่ย้าย

ชุดแรก ๆ เราให้แต่ละไฟล์เปิด PR ของตัวเอง แต่ละ PR ก็สั่ง CI หนึ่งรอบบนเครื่อง CI เครื่องเดียวกัน จน build ของ deploy ที่ปกติใช้ 4 นาทีกลายเป็น 18 นาที ตั้งแต่ชุดที่สามจึงเปลี่ยนมาใช้ branch บนเครื่องแล้วรวมเป็นชุดอย่างที่เล่าข้างบน

บทถัดไป

อ่านต่อ

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

ล่าสุด

ดูทั้งหมด →