บทนี้เป็นเรื่องที่ดูเล็กที่สุดในคอร์ส แต่ทุกบทหลังจากนี้ใช้มันหมด เวลาเราแยก hook ออกจาก component หรือดึงฟังก์ชันออกมาให้ test ได้ เราต้องตัดสินทุกครั้งว่าไฟล์ใหม่จะไปอยู่ที่ไหน ถ้าไม่มีกฎ ไฟล์ใหม่ก็จะไปกองที่ hooks/ กับ utils/ จนโฟลเดอร์ทั้งสองยาวเป็นร้อยไฟล์ และไม่มีใครรู้ว่าไฟล์ไหนยังมีคนใช้
กฎของบทนี้
- วางไฟล์ไว้ใกล้ที่ใช้ hook, helper หรือ component ย่อยที่มี feature เดียวใช้ อยู่ในโฟลเดอร์ของ feature นั้น
- ย้ายไปที่กลางเมื่อมีคนที่สองใช้ เช่น
components/uiหรือlib/hooksไม่ย้ายไปเผื่อไว้ก่อน - แยกไฟล์ใหญ่เป็นโฟลเดอร์ แต่ไฟล์เดิมยังอยู่ที่ path เดิม ไฟล์เดิมกลายเป็นตัวประกอบที่ import ชิ้นส่วนจากโฟลเดอร์ใหม่ และยัง export ชื่อเดิมครบ
- ไม่สร้าง barrel file ใหม่ คือไฟล์
index.tsที่มีแต่export * from './…'ให้ import ตรงจากไฟล์ที่ต้องการ
อาการที่บอกว่าโครงโฟลเดอร์เริ่มเป็นปัญหา
ลองเปิดโปรเจกต์ของเราดู ถ้าเจอข้อไหนต่อไปนี้ ก็ถึงเวลาจัดใหม่แล้ว
hooks/หรือutils/ที่ใหญ่ขึ้นเรื่อย ๆ และไฟล์ส่วนใหญ่ในนั้นมีคนเรียกแค่ที่เดียว ลองนับดูง่าย ๆ ว่าแต่ละไฟล์ถูก import จากกี่ไฟล์ ถ้าเกินครึ่งมีคนใช้ที่เดียว แปลว่าไฟล์พวกนั้นควรอยู่ข้างคนที่ใช้- ทุกครั้งที่แยกไฟล์ จะมี
index.tsใหม่ตามมาด้วย เพื่อให้ import สั้นลง - barrel ตัวเดียว export ทั้ง component ฝั่ง client และโค้ดฝั่ง server เช่น export ทั้ง
AddressFormและฟังก์ชันที่อ่านฐานข้อมูล - แก้ feature เดียวแต่ต้องเปิดไฟล์ในสี่ห้าโฟลเดอร์ component อยู่ที่หนึ่ง hook อยู่อีกที่ type อยู่อีกที่ ฟังก์ชันคำนวณอยู่อีกที่
ข้อสุดท้ายเป็นอาการที่ชัดที่สุด และเป็นต้นทุนที่ทั้งคนและ AI agent จ่ายทุกวัน agent ต้องค้นหาไฟล์ที่เกี่ยวข้องก่อนแก้ ยิ่งไฟล์กระจาย ยิ่งต้องค้นหลายรอบ และยิ่งมีโอกาสพลาดไฟล์ที่ควรแก้ไป
จัดตามชนิดไฟล์ กับจัดตาม feature
โปรเจกต์ React ส่วนใหญ่เริ่มจากการจัดตามชนิดไฟล์ เพราะ template ส่วนใหญ่ก็ให้มาแบบนั้น ตอนโปรเจกต์ยังเล็กก็ใช้ได้ดี ปัญหามาตอนโปรเจกต์โต
| จัดตามชนิดไฟล์ | จัดตาม feature | |
|---|---|---|
| หน้าตา | components/, hooks/, utils/, types/ |
checkout/, product/, account/ แต่ละอันมีของตัวเอง |
| แก้ feature เดียว | เปิดหลายโฟลเดอร์ | เปิดโฟลเดอร์เดียว |
| ลบ feature ทิ้ง | ต้องไล่ดูว่าไฟล์ไหนใน hooks/ เป็นของ feature นี้ |
ลบโฟลเดอร์เดียว |
| ของที่ใช้ร่วมกัน | ปนอยู่กับของที่ใช้ที่เดียว | อยู่ที่กลาง แยกชัดว่าเป็นของกลาง |
จัดตาม feature ไม่ได้แปลว่าห้ามมีโฟลเดอร์กลาง โฟลเดอร์กลางยังมีอยู่ แต่จะเหลือเฉพาะของที่ใช้หลายที่จริง ๆ เช่น ปุ่ม dialog และ hook อย่าง useDebounce พอเห็นไฟล์ใน components/ui เราก็รู้ทันทีว่าแก้แล้วกระทบหลายหน้า
Next.js ให้วางไฟล์ใน app/ ได้เลย
ใน App Router ไฟล์ที่อยู่ในโฟลเดอร์ app/ จะกลายเป็นหน้าเว็บก็ต่อเมื่อชื่อไฟล์เป็นชื่อพิเศษอย่าง page.tsx หรือ route.ts ไฟล์อื่นที่วางไว้ข้าง ๆ จะไม่มี URL ของตัวเอง เอกสารของ Next.js เรียกแบบนี้ว่า colocation เราจึงวาง component ที่หน้าเดียวใช้ไว้ข้าง page.tsx ของหน้านั้นได้เลย
ถ้าอยากให้ชัดว่าโฟลเดอร์ไหนเป็นของภายใน Next.js มี private folder คือโฟลเดอร์ที่ชื่อขึ้นต้นด้วย _ เช่น _components โฟลเดอร์แบบนี้และทุกอย่างข้างในจะไม่ถูกนับเป็น route เลย ใช้แยกชิ้นส่วนของ UI ออกจากไฟล์ route ได้ชัดขึ้น และไม่ต้องกลัวว่าชื่อไฟล์จะไปชนกับชื่อพิเศษของ Next.js ในอนาคต
app/
orders/
[id]/
page.tsx ← server component ของหน้า
_components/
order-summary.tsx ← ใช้แค่หน้านี้
shipment-timeline.tsx
cancel-button.tsx ← 'use client' เฉพาะปุ่ม
Next.js เองไม่ได้บังคับว่าต้องวางไฟล์แบบไหน จะวางของใน app/ หรือแยกไว้นอก app/ ก็ได้ ที่สำคัญคือทั้งโปรเจกต์ใช้แบบเดียวกัน
แยกไฟล์ใหญ่โดยเก็บไฟล์เดิมไว้ที่เดิม
ทีนี้มาถึงตอนที่ component ตัวหนึ่งยาวเกินไปจนต้องแยก เช่น หน้า checkout ยาวพันกว่าบรรทัด มี hook สองตัวกับฟังก์ชันคำนวณค่าส่งอยู่ในโฟลเดอร์กลาง ทางที่เราใช้คือสร้างโฟลเดอร์ชื่อเดียวกับไฟล์ไว้ข้าง ๆ แล้วย้ายชิ้นส่วนเข้าไป
# ก่อน # หลัง
components/ components/
checkout-view.tsx (1,080 บรรทัด) checkout-view.tsx (310 บรรทัด) ← path เดิม
hooks/ checkout-view/
use-checkout-totals.ts use-checkout-totals.ts
use-coupon.ts use-coupon.ts
utils/ shipping-for.ts
shipping-for.ts shipping-for.test.ts
payment-section.tsx
address-section.tsx
ไฟล์ checkout-view.tsx ยังอยู่ที่เดิม ทำหน้าที่เป็นตัวประกอบ คือรับ props เรียก hook แล้ววางชิ้นส่วนลงในโครง JSX หน้าที่ import CheckoutView อยู่ไม่ต้องแก้สักบรรทัด
// components/checkout-view.tsx
'use client';
import { useCheckoutTotals } from './checkout-view/use-checkout-totals';
import { useCoupon } from './checkout-view/use-coupon';
import { AddressSection } from './checkout-view/address-section';
import { PaymentSection } from './checkout-view/payment-section';
export function CheckoutView({ cart, addresses }: CheckoutViewProps) {
const coupon = useCoupon(cart.id);
const totals = useCheckoutTotals(cart, coupon.applied);
return (
<div className="grid gap-8 lg:grid-cols-[1fr_360px]">
<AddressSection addresses={addresses} />
<PaymentSection totals={totals} coupon={coupon} />
</div>
);
}
ข้อดีของการเก็บ path เดิมมีมากกว่าที่คิด diff ของการแยกจะมีแต่ไฟล์ใหม่กับไฟล์เดิมที่สั้นลง ไม่มีไฟล์อื่นในโปรเจกต์ที่ต้องแก้ import ตาม คนรีวิวจึงดูแค่ว่าโค้ดที่ย้ายไปเหมือนเดิมไหม และถ้าต้องย้อนกลับ ก็ revert ได้ใน commit เดียว
ส่วน hooks/use-coupon.ts กับ utils/shipping-for.ts เดิม ย้ายเข้ามาในโฟลเดอร์ของ checkout ด้วย เพราะมีแค่ checkout ที่ใช้ ถ้าวันหนึ่งหน้าตะกร้าต้องใช้ shippingFor ด้วย ค่อยย้ายมันออกไปที่ lib/ ตอนนั้น
ของกลางก็ต้องมีที่อยู่ที่ชัด
พอของส่วนใหญ่ย้ายไปอยู่กับ feature แล้ว โฟลเดอร์กลางจะเล็กลงมาก และควรแบ่งตามว่าของนั้นทำงานฝั่งไหน เพราะใน Next.js คำถามแรกของทุกไฟล์คือรันบน server หรือในเบราว์เซอร์
components/ui/ ← ปุ่ม input dialog ที่ทุกหน้าใช้ ไม่รู้จักข้อมูลของ feature ไหน
lib/hooks/ ← hook ทั่วไปอย่าง useDebounce, useMediaQuery (ฝั่ง client)
lib/format/ ← ฟังก์ชันล้วน เช่น formatBaht, formatThaiDate ใช้ได้ทั้งสองฝั่ง
lib/data/ ← อ่านฐานข้อมูล มี import 'server-only' ที่บรรทัดแรก
ชื่อโฟลเดอร์ไม่สำคัญเท่าการที่ทุกคนรู้ว่าไฟล์ประเภทไหนไปอยู่ที่ไหน ให้ดูข้อสุดท้าย โฟลเดอร์ที่อ่านฐานข้อมูลใส่ import 'server-only' ไว้ทุกไฟล์ ถ้ามีใคร import มันจาก component ฝั่ง client โดยไม่ตั้งใจ build จะ error ทันที เรื่องเส้นแบ่งนี้มีรายละเอียดอีกในบทServer Component กับ 'use client'
type ก็ใช้กฎเดียวกัน type ของ props อยู่ในไฟล์ component นั้นเอง type ที่ทั้ง feature ใช้อยู่ใน types.ts ของ feature ส่วน type ที่มาจากฐานข้อมูลหรือ API ที่ใช้ทั้งโปรเจกต์ค่อยอยู่ที่กลาง ไม่ต้องมีโฟลเดอร์ types/ ที่รวม type ทุกอย่างไว้ด้วยกัน
barrel file ทำให้ import ชื่อเดียวโหลดทั้งโฟลเดอร์
พอแยกไฟล์ออกเป็นโฟลเดอร์ หลายคนจะอยากสร้าง index.ts ไว้ เพื่อให้เขียน import สั้น ๆ ได้แบบนี้
// components/checkout-view/index.ts — ไม่ต้องสร้างไฟล์นี้
export * from './payment-section';
export * from './address-section';
export * from './use-coupon';
export * from './shipping-for';
// ที่อื่นเขียนสั้นลงก็จริง
import { shippingFor } from '@/components/checkout-view';
ไฟล์แบบนี้เรียกว่า barrel file ดูสะอาดดี แต่มีต้นทุนสามอย่าง
import ชื่อเดียว ได้มาทั้งก้อน bundler ต้องอ่านทุกไฟล์ที่ barrel re-export เพื่อดูว่ามีโค้ดที่รันตอนโหลด module ไหม เอกสารของ Next.js บอกตรง ๆ ว่า barrel file ทำให้ build ช้า และแนะนำให้ import ตรงจากไฟล์ที่ต้องการ ตัวอย่างข้างบน ถ้าหน้าตะกร้า import แค่ shippingFor ก็จะได้ PaymentSection กับทุกอย่างที่ PaymentSection import ติดมาด้วย
ข้ามเส้นแบ่ง server กับ client โดยไม่รู้ตัว ใน Next.js ไฟล์ที่มี 'use client' จะทำให้ทุกอย่างที่มันลาก import ไปกลายเป็นโค้ดฝั่งเบราว์เซอร์ ถ้า barrel ตัวหนึ่ง export ทั้ง component ฝั่ง client และฟังก์ชันที่อ่านฐานข้อมูล component ฝั่ง client ที่ import ผ่าน barrel นี้ก็มีโอกาสลากโค้ดฝั่ง server ตามไปด้วย ถ้าโชคดีจะ build ไม่ผ่าน ถ้าโชคร้ายจะ build ผ่านและส่งโค้ดที่ไม่ควรออกไปให้เบราว์เซอร์
หาไม่เจอว่าใครใช้อะไร ค้นชื่อไฟล์ payment-section ทั้งโปรเจกต์แล้วเจอแค่ใน index.ts เพราะทุกคน import ผ่าน barrel พอจะลบหรือย้ายไฟล์ ก็ต้องไล่ดูชื่อที่ export ทีละตัวแทน
import ตรงยาวกว่าไม่กี่ตัวอักษร แต่บอกชัดว่าใช้อะไรจากไฟล์ไหน editor ก็เติม path ให้อยู่แล้ว
barrel ที่ไม่ต้องรื้อ
กฎข้อนี้คือ "ไม่สร้างใหม่" ไม่ใช่ "รื้อของเดิมทิ้งทั้งหมด" barrel บางตัวมีเหตุผลที่จะอยู่
- จุดเข้าของ package ใน monorepo เช่น
packages/ui/src/index.tsที่เป็น public API ของ package คนนอก package ควรเห็นแค่สิ่งที่ตั้งใจ export - ไฟล์รวม schema ของฐานข้อมูล ที่ ORM ต้องการให้รวมไว้ที่เดียว
- package ภายนอกที่เป็น barrel อยู่แล้ว เช่น icon library ใช้
optimizePackageImportsในnext.configให้ Next.js แปลง import ให้โหลดเฉพาะที่ใช้
เมื่อไหร่ไม่ต้องทำตามกฎนี้
- โปรเจกต์เล็กที่มีไม่กี่หน้า จัดตามชนิดไฟล์ก็ยังอ่านง่ายอยู่ ไม่ต้องรีบจัดใหม่ ค่อยจัดตอนที่เริ่มเห็นอาการข้างบน
- ไฟล์ที่ test mock ด้วย path เช่น
vi.mock('@/hooks/use-cart')ถ้าย้ายไฟล์ path ในvi.mockก็ต้องย้ายตาม ถ้ามีหลายสิบที่ ให้ไฟล์เดิมอยู่ที่เดิมก่อน แล้วย้ายเฉพาะเนื้อในออกไป - ตั้งชื่อโฟลเดอร์ให้เป็นแบบเดียวทั้งโปรเจกต์ การมีไฟล์
checkout-view.tsxคู่กับโฟลเดอร์checkout-view/ใช้ได้ แต่บางเครื่องมืออาจสับสนระหว่างสองอัน ถ้าทีมชอบแยกให้ชัดกว่านี้ ใช้_checkout-view-parts/แบบ private folder ก็ได้ เลือกแบบเดียวแล้วใช้ทั้งโปรเจกต์
จากงานจริง
ร้านค้าออนไลน์ Next.js 16 กับ CMS ที่เป็น Next.js admin ใน pnpm monorepo ที่เราแยก component ใหญ่ไปราว 70 ไฟล์ ทุกครั้งใช้วิธีเดียวกัน คือสร้างโฟลเดอร์ข้าง ๆ แล้วเก็บไฟล์เดิมเป็นตัวประกอบ ทั้ง 70 ไฟล์ยังอยู่ที่ path เดิม ไม่มีไฟล์ไหนในโปรเจกต์ต้องแก้ import ตาม การแยกแต่ละครั้งจึงรีวิวได้ทีละไฟล์ และ merge ลำดับไหนก่อนหลังก็ได้
ที่ต้องระวังคือ test บางตัวเปิดอ่าน source ของไฟล์ตาม path เพื่อเช็กว่ายังมีโค้ดบางบรรทัดอยู่ไหม พอโค้ดบรรทัดนั้นย้ายไปอยู่ในโฟลเดอร์ใหม่ test พวกนี้ไม่ได้ fail แต่ไปอ่านไฟล์ที่ไม่มีโค้ดนั้นแล้วก็ผ่านเหมือนเดิม จึงต้องไล่แก้ test ให้ชี้ไปที่ไฟล์ใหม่ทุกครั้งที่ย้าย เรื่องนี้มีรายละเอียดอีกในบทที่ว่าด้วยการแยก component ใหญ่โดยไม่เปลี่ยนพฤติกรรม





